commit b34f82bf6a32c6f3202fa1ed246fa1d3509fbb57 Author: J62 Date: Wed Mar 12 19:15:12 2025 -0700 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23a67f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +*.class + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.ear + +# virtual machine crash logs +hs_err_pid* + +# android +local.properties + +# gradle +/.gradle/ +build/ + +# idea +/.idea/ +*.iml +/bin/ +/.settings/ +/.classpath +/.project +target diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f349e6a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,45 @@ +## How can I contribute? + +* create issues for bugs you find +* create pull requests to fix bigs, add features, clean up code, etc. +* improve the documentation +* chat with us on the freenode IRC server at #open-m3u8 + +## What can I work on? + +We do not yet support the full m3u8 specification! So a great way to help is by adding support for more tags in the specification: + +http://tools.ietf.org/html/draft-pantos-http-live-streaming-14 + +The issues page is another good place to look for ways to contribute. + +## Compatibility + +This library needs to support Android which means we are limited to Java 7 sans try-with-resources. + +## Code Style + +* 4 spaces per indent - no tab characters +* mPrefix private members +* opening braces on the same line +* when wrapping long method calls, put each argument on its own line +* when building long fluent builders, put each method in the chain on its own line + +## Merging + +We use a rebase / cherry-pick strategy to merging code. This is to maintain a legible git history on the master branch. This has a few implications: + +* it is best if you rebase your branches onto master to fix merge conflicts instead of merging +* your commits may be squashed, reordered, reworded, or edited when merged +* your pull request will be marked closed instead of merged but will be linked to the closing commit +* your branch will not remain tracked by this repository + +## Working with the Code + +### com.iheartradio.m3u8 package + +Everything not meant to be visible to the public API must be package protected. If a whole class is package protected, then you may mark the fields public since they will still not be visible. + +### com.iheartradio.m3u8.data package + +The data structures in this package reflect the structure of a playlist based on the specification. They are part of the public API and must be immutable. The `Playlist` is the result of parsing and the input of writing. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4712f20 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2014 iHeartMedia Inc. + +Source: https://github.com/iheartradio/open-m3u8 + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b16bd92 --- /dev/null +++ b/README.md @@ -0,0 +1,173 @@ +# Open M3U8 (working name) + +## Description + +This is an open source M3U8 playlist parser and writer java library that attempts to conform to this specification: + +http://tools.ietf.org/html/draft-pantos-http-live-streaming-16 + +Currently the functionality is more than sufficient for our needs. However, there is still a lot of work to be done before we have full compliance. Pull requests are welcome! + +## Rationale + +We would like to give back to the open source community surrounding Android that has helped make iHeartRadio a success. By using the MIT license we hope to make this code as usable as possible. + +## Artifacts + +We now have artifacts in Maven Central! Artifacts are typically built with Java 7. + +### Gradle + +``` +dependencies { + compile 'com.iheartradio.m3u8:open-m3u8:0.2.4' +} +``` + +### Maven + +``` + + com.iheartradio.m3u8 + open-m3u8 + 0.2.4 + +``` + +## Getting started + +Important: The public API is still volatile. It will remain subject to frequent change until a 1.0.0 release is made. + +Getting started with parsing is quite easy: Get a `PlaylistParser` and specify the format. + +```java +InputStream inputStream = ... +PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8); +Playlist playlist = parser.parse(); +``` + +Creating a new `Playlist` works via `Builder`s and their fluent `with*()` methods. On each `build()` method the provided parameters are validated: + +```java +TrackData trackData = new TrackData.Builder() + .withTrackInfo(new TrackInfo(3.0f, "Example Song")) + .withPath("example.mp3") + .build(); + +List tracks = new ArrayList(); +tracks.add(trackData); + +MediaPlaylist mediaPlaylist = new MediaPlaylist.Builder() + .withMediaSequenceNumber(1) + .withTargetDuration(3) + .withTracks(tracks) + .build(); + +Playlist playlist = new Playlist.Builder() + .withCompatibilityVersion(5) + .withMediaPlaylist(mediaPlaylist) + .build(); +``` + +The Playlist is similar to a C style union of a `MasterPlaylist` and `MediaPlaylist` in that it has one or the other but not both. You can check with `Playlist.hasMasterPlaylist()` or `Playlist.hasMediaPlaylist()` which type you got. + +Modifying an existing `Playlist` works similar to creating via the `Builder`s. Also, each data class provides a `buildUpon()` method to generate a new `Builder` with all the data from the object itself: + +```java +TrackData additionalTrack = new TrackData.Builder() + .withTrackInfo(new TrackInfo(3.0f, "Additional Song")) + .withPath("additional.mp3") + .build(); + +List updatedTracks = new ArrayList(playlist.getMediaPlaylist().getTracks()); +updatedTracks.add(additionalTrack); + +MediaPlaylist updatedMediaPlaylist = playlist.getMediaPlaylist() + .buildUpon() + .withTracks(updatedTracks) + .build(); + +Playlist updatedPlaylist = playlist.buildUpon() + .withMediaPlaylist(updatedMediaPlaylist) + .build(); +``` + +A `PlaylistWriter` can be obtained directly or via its builder. + +```java +OutputStream outputStream = ... + +PlaylistWriter writer = new PlaylistWriter(outputStream, Format.EXT_M3U, Encoding.UTF_8); +writer.write(updatedPlaylist); + +writer = new PlaylistWriter.Builder() + .withOutputStream(outputStream) + .withFormat(Format.EXT_M3U) + .withEncoding(Encoding.UTF_8) + .build(); + +writer.write(updatedPlaylist); +``` + +causing this playlist to be written: + +``` +#EXTM3U +#EXT-X-VERSION:5 +#EXT-X-TARGETDURATION:3 +#EXT-X-MEDIA-SEQUENCE:1 +#EXTINF:3.0,Example Song +example.mp3 +#EXTINF:3.0,Additional Song +additional.mp3 +#EXT-X-ENDLIST +``` + +Currently, writing multiple playlists with the same writer is not supported. + +## Advanced usage + +### Parsing mode + +The parser supports a mode configuration - by default it operats in a `strict` mode which attemps to adhere to the specification as much as possible. + +Providing the parser a `ParsingMode` you can relax some of the requirements. Two parsing modes are made available, or you can build your own custom mode. +```java +ParsingMode.LENIENT // lenient about everything +ParsingMode.STRICT // strict about everything +``` +Example: +```java +InputStream inputStream = ... +PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); +Playlist playlist = parser.parse(); +if (playlist.hasMasterPlaylist() && playlist.getMasterPlaylist().hasUnknownTags()) { + System.err.println( + playlist.getMasterPlaylist().getUnknownTags()); +} else if (playlist.hasMediaPlaylist() && playlist.getMediaPlaylist().hasUnknownTags()) { + System.err.println( + playlist.getMediaPlaylist().getUnknownTags()); +} else { + System.out.println("Parsing without unknown tags successful"); +} +``` + +======= +## Build + +This is a Gradle project. Known compatible gradle versions: + +- 2.1 +- 2.4 + +Build and test via: +``` +gradle build +``` +The output can be found in the generated /build/libs/ dir. + +Cobertura is configured to report the line coverage: +``` +gradle cobertura +``` +producing the coverage report at `build/reports/cobertura/index.html` diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..289661c --- /dev/null +++ b/pom.xml @@ -0,0 +1,25 @@ + + + 4.0.0 + + com.iheartradio.m3u8 + open-m3u8 + 0.2.7-CTBREC + + + 1.7 + 1.7 + UTF-8 + + + + + junit + junit + 4.11 + + + + diff --git a/src/main/java/com/iheartradio/m3u8/Attribute.java b/src/main/java/com/iheartradio/m3u8/Attribute.java new file mode 100644 index 0000000..bf93800 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/Attribute.java @@ -0,0 +1,20 @@ +package com.iheartradio.m3u8; + +class Attribute { + public final String name; + public final String value; + + Attribute(String name, String value) { + this.name = name; + this.value = value; + } + + @Override + public String toString() { + return new StringBuilder() + .append("(Attribute") + .append(" name=").append(name) + .append(" value=").append(value) + .append(")").toString(); + } +} diff --git a/src/main/java/com/iheartradio/m3u8/AttributeParser.java b/src/main/java/com/iheartradio/m3u8/AttributeParser.java new file mode 100644 index 0000000..8597847 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/AttributeParser.java @@ -0,0 +1,6 @@ +package com.iheartradio.m3u8; + + +interface AttributeParser { + void parse(Attribute attribute, Builder builder, ParseState state) throws ParseException; +} diff --git a/src/main/java/com/iheartradio/m3u8/AttributeWriter.java b/src/main/java/com/iheartradio/m3u8/AttributeWriter.java new file mode 100644 index 0000000..58afd0c --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/AttributeWriter.java @@ -0,0 +1,8 @@ +package com.iheartradio.m3u8; + +interface AttributeWriter { + + String write(T attributes) throws ParseException; + + boolean containsAttribute(T attributes); +} diff --git a/src/main/java/com/iheartradio/m3u8/BaseM3uParser.java b/src/main/java/com/iheartradio/m3u8/BaseM3uParser.java new file mode 100644 index 0000000..9006491 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/BaseM3uParser.java @@ -0,0 +1,25 @@ +package com.iheartradio.m3u8; + +import java.io.EOFException; +import java.io.InputStream; + +abstract class BaseM3uParser implements IPlaylistParser { + protected final M3uScanner mScanner; + protected final Encoding mEncoding; + + BaseM3uParser(InputStream inputStream, Encoding encoding) { + mScanner = new M3uScanner(inputStream, encoding); + mEncoding = encoding; + } + + @Override + public boolean isAvailable() { + return mScanner.hasNext(); + } + + final void validateAvailable() throws EOFException { + if (!isAvailable()) { + throw new EOFException(); + } + } +} diff --git a/src/main/java/com/iheartradio/m3u8/Constants.java b/src/main/java/com/iheartradio/m3u8/Constants.java new file mode 100644 index 0000000..f72a718 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/Constants.java @@ -0,0 +1,121 @@ +package com.iheartradio.m3u8; + +import java.util.Arrays; +import java.util.List; +import java.util.regex.Pattern; + +final class Constants { + public static final String MIME_TYPE = "application/vnd.apple.mpegurl"; + public static final String MIME_TYPE_COMPATIBILITY = "audio/mpegurl"; + + public static final String ATTRIBUTE_SEPARATOR = "="; + public static final char COMMA_CHAR = ','; + public static final String COMMA = Character.toString(COMMA_CHAR); + public static final String ATTRIBUTE_LIST_SEPARATOR = COMMA; + public static final String LIST_SEPARATOR = "/"; + public static final String COMMENT_PREFIX = "#"; + public static final String EXT_TAG_PREFIX = "#EXT"; + public static final String EXT_TAG_END = ":"; + public static final String WRITE_NEW_LINE = "\n"; + public static final String PARSE_NEW_LINE = "\\r?\\n"; + + // extension tags + + public static final String EXTM3U_TAG = "EXTM3U"; + public static final String EXT_X_VERSION_TAG = "EXT-X-VERSION"; + + // master playlist tags + + public static final String URI = "URI"; + public static final String BYTERANGE = "BYTERANGE"; + + public static final String EXT_X_MEDIA_TAG = "EXT-X-MEDIA"; + public static final String TYPE = "TYPE"; + public static final String GROUP_ID = "GROUP-ID"; + public static final String LANGUAGE = "LANGUAGE"; + public static final String ASSOCIATED_LANGUAGE = "ASSOC-LANGUAGE"; + public static final String NAME = "NAME"; + public static final String DEFAULT = "DEFAULT"; + public static final String AUTO_SELECT = "AUTOSELECT"; + public static final String FORCED = "FORCED"; + public static final String IN_STREAM_ID = "INSTREAM-ID"; + public static final String CHARACTERISTICS = "CHARACTERISTICS"; + public static final String CHANNELS = "CHANNELS"; + + public static final String EXT_X_STREAM_INF_TAG = "EXT-X-STREAM-INF"; + public static final String EXT_X_I_FRAME_STREAM_INF_TAG = "EXT-X-I-FRAME-STREAM-INF"; + public static final String BANDWIDTH = "BANDWIDTH"; + public static final String AVERAGE_BANDWIDTH = "AVERAGE-BANDWIDTH"; + public static final String CODECS = "CODECS"; + public static final String RESOLUTION = "RESOLUTION"; + public static final String FRAME_RATE = "FRAME-RATE"; + public static final String VIDEO = "VIDEO"; + public static final String PROGRAM_ID = "PROGRAM-ID"; + + public static final String AUDIO = "AUDIO"; + public static final String SUBTITLES = "SUBTITLES"; + public static final String CLOSED_CAPTIONS = "CLOSED-CAPTIONS"; + + + // media playlist tags + + public static final String EXT_X_PLAYLIST_TYPE_TAG = "EXT-X-PLAYLIST-TYPE"; + public static final String EXT_X_PROGRAM_DATE_TIME_TAG = "EXT-X-PROGRAM-DATE-TIME"; + public static final String EXT_X_TARGETDURATION_TAG = "EXT-X-TARGETDURATION"; + public static final String EXT_X_START_TAG = "EXT-X-START"; + public static final String TIME_OFFSET = "TIME-OFFSET"; + public static final String PRECISE = "PRECISE"; + + public static final String EXT_X_MEDIA_SEQUENCE_TAG = "EXT-X-MEDIA-SEQUENCE"; + public static final String EXT_X_ALLOW_CACHE_TAG = "EXT-X-ALLOW-CACHE"; + public static final String EXT_X_ENDLIST_TAG = "EXT-X-ENDLIST"; + public static final String EXT_X_I_FRAMES_ONLY_TAG = "EXT-X-I-FRAMES-ONLY"; + public static final String EXT_X_DISCONTINUITY_TAG = "EXT-X-DISCONTINUITY"; + + // media segment tags + + public static final String EXTINF_TAG = "EXTINF"; + public static final String EXT_X_KEY_TAG = "EXT-X-KEY"; + public static final String METHOD = "METHOD"; + public static final String IV = "IV"; + public static final String KEY_FORMAT = "KEYFORMAT"; + public static final String KEY_FORMAT_VERSIONS = "KEYFORMATVERSIONS"; + public static final String EXT_X_MAP = "EXT-X-MAP"; + public static final String EXT_X_BYTERANGE_TAG = "EXT-X-BYTERANGE"; + + // regular expressions + public static final String YES = "YES"; + public static final String NO = "NO"; + private static final String INTEGER_REGEX = "\\d+"; + private static final String SIGNED_FLOAT_REGEX = "-?\\d*\\.?\\d*"; + private static final String TIMESTAMP_REGEX = "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{3})?(?:Z?|[\\+-]\\d{2}(:?\\d{2})?)?"; + private static final String BYTERANGE_REGEX = "(" + INTEGER_REGEX + ")(?:@(" + INTEGER_REGEX + "))?"; + + public static final Pattern HEXADECIMAL_PATTERN = Pattern.compile("^0[x|X]([0-9A-F]+)$"); + public static final Pattern RESOLUTION_PATTERN = Pattern.compile("^(" + INTEGER_REGEX + ")x(" + INTEGER_REGEX + ")$"); + + public static final Pattern EXT_X_VERSION_PATTERN = Pattern.compile("^#" + EXT_X_VERSION_TAG + EXT_TAG_END + "(" + INTEGER_REGEX + ")$"); + public static final Pattern EXT_X_TARGETDURATION_PATTERN = Pattern.compile("^#" + EXT_X_TARGETDURATION_TAG + EXT_TAG_END + "(" + INTEGER_REGEX + ")$"); + public static final Pattern EXT_X_MEDIA_SEQUENCE_PATTERN = Pattern.compile("^#" + EXT_X_MEDIA_SEQUENCE_TAG + EXT_TAG_END + "(" + INTEGER_REGEX + ")$"); + public static final Pattern EXT_X_PLAYLIST_TYPE_PATTERN = Pattern.compile("^#" + EXT_X_PLAYLIST_TYPE_TAG + EXT_TAG_END + "(EVENT|VOD)$"); + public static final Pattern EXT_X_PROGRAM_DATE_TIME_PATTERN = Pattern.compile("^#" + EXT_X_PROGRAM_DATE_TIME_TAG + EXT_TAG_END + "(" + TIMESTAMP_REGEX + ")$"); + public static final Pattern EXT_X_MEDIA_IN_STREAM_ID_PATTERN = Pattern.compile("^CC[1-4]|SERVICE(?:[1-9]|[1-5]\\d|6[0-3])$"); + public static final Pattern EXTINF_PATTERN = Pattern.compile("^#" + EXTINF_TAG + EXT_TAG_END + "(" + SIGNED_FLOAT_REGEX + ")(?:,(.+)?)?$"); + public static final Pattern EXT_X_ENDLIST_PATTERN = Pattern.compile("^#" + EXT_X_ENDLIST_TAG + "$"); + public static final Pattern EXT_X_I_FRAMES_ONLY_PATTERN = Pattern.compile("^#" + EXT_X_I_FRAMES_ONLY_TAG); + public static final Pattern EXT_X_DISCONTINUITY_PATTERN = Pattern.compile("^#" + EXT_X_DISCONTINUITY_TAG + "$"); + public static final Pattern EXT_X_BYTERANGE_PATTERN = Pattern.compile("^#" + EXT_X_BYTERANGE_TAG + EXT_TAG_END + BYTERANGE_REGEX + "$"); + public static final Pattern EXT_X_BYTERANGE_VALUE_PATTERN = Pattern.compile("^" + BYTERANGE_REGEX + "$"); + + // other + + public static final int[] UTF_8_BOM_BYTES = {0xEF, 0xBB, 0xBF}; + public static final char UNICODE_BOM = '\uFEFF'; + public static final int MAX_COMPATIBILITY_VERSION = Integer.MAX_VALUE; + public static final int IV_SIZE = 16; + //Against the spec but used by Adobe + public static final int IV_SIZE_ALTERNATIVE = 32; + public static final String DEFAULT_KEY_FORMAT = "identity"; + public static final String NO_CLOSED_CAPTIONS = "NONE"; + public static final List DEFAULT_KEY_FORMAT_VERSIONS = Arrays.asList(1); +} diff --git a/src/main/java/com/iheartradio/m3u8/Encoding.java b/src/main/java/com/iheartradio/m3u8/Encoding.java new file mode 100644 index 0000000..d3c7279 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/Encoding.java @@ -0,0 +1,41 @@ +package com.iheartradio.m3u8; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +public enum Encoding { + UTF_8("utf-8", true), + WINDOWS_1252("windows-1252", false); + + private static final Map sMap = new HashMap(); + + final String value; + final boolean supportsByteOrderMark; + + static { + for (Encoding mediaType : Encoding.values()) { + sMap.put(mediaType.value, mediaType); + } + } + + private Encoding(String value, boolean supportsByteOrderMark) { + this.value = value; + this.supportsByteOrderMark = supportsByteOrderMark; + } + + /** + * @return the encoding for the given value if supported, if the encoding is unsupported or null, null will be returned + */ + public static Encoding fromValue(String value) { + if (value == null) { + return null; + } else { + return sMap.get(value.toLowerCase(Locale.US)); + } + } + + public String getValue() { + return value; + } +} diff --git a/src/main/java/com/iheartradio/m3u8/ExtLineParser.java b/src/main/java/com/iheartradio/m3u8/ExtLineParser.java new file mode 100644 index 0000000..4aa4403 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/ExtLineParser.java @@ -0,0 +1,144 @@ +package com.iheartradio.m3u8; + +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; + +import com.iheartradio.m3u8.data.Playlist; +import com.iheartradio.m3u8.data.StartData; + +class ExtLineParser implements LineParser { + private final IExtTagParser mTagParser; + + ExtLineParser(IExtTagParser tagParser) { + mTagParser = tagParser; + } + + @Override + public void parse(String line, ParseState state) throws ParseException { + if (mTagParser.hasData()) { + if (line.indexOf(Constants.EXT_TAG_END) != mTagParser.getTag().length() + 1) { + throw ParseException.create(ParseExceptionType.MISSING_EXT_TAG_SEPARATOR, mTagParser.getTag(), line); + } + } + } + + static final IExtTagParser EXTM3U_HANDLER = new IExtTagParser() { + @Override + public String getTag() { + return Constants.EXTM3U_TAG; + } + + @Override + public boolean hasData() { + return false; + } + + @Override + public void parse(String line, ParseState state) throws ParseException { + if (state.isExtended()) { + throw ParseException.create(ParseExceptionType.MULTIPLE_EXT_TAG_INSTANCES, getTag(), line); + } + + state.setExtended(); + } + }; + + static final IExtTagParser EXT_UNKNOWN_HANDLER = new IExtTagParser() { + @Override + public String getTag() { + return null; + } + + @Override + public boolean hasData() { + return false; + } + + @Override + public void parse(String line, ParseState state) throws ParseException { + state.unknownTags.add(line); + } + }; + + static final IExtTagParser EXT_X_VERSION_HANDLER = new IExtTagParser() { + private final ExtLineParser lineParser = new ExtLineParser(this); + + @Override + public String getTag() { + return Constants.EXT_X_VERSION_TAG; + } + + @Override + public boolean hasData() { + return true; + } + + @Override + public void parse(String line, ParseState state) throws ParseException { + lineParser.parse(line, state); + + final Matcher matcher = ParseUtil.match(Constants.EXT_X_VERSION_PATTERN, line, getTag()); + + if (state.getCompatibilityVersion() != ParseState.NONE) { + throw ParseException.create(ParseExceptionType.MULTIPLE_EXT_TAG_INSTANCES, getTag(), line); + } + + final int compatibilityVersion = ParseUtil.parseInt(matcher.group(1), getTag()); + + if (compatibilityVersion < Playlist.MIN_COMPATIBILITY_VERSION) { + throw ParseException.create(ParseExceptionType.INVALID_COMPATIBILITY_VERSION, getTag(), line); + } + + if (compatibilityVersion > Constants.MAX_COMPATIBILITY_VERSION) { + throw ParseException.create(ParseExceptionType.UNSUPPORTED_COMPATIBILITY_VERSION, getTag(), line); + } + + state.setCompatibilityVersion(compatibilityVersion); + } + }; + + static final IExtTagParser EXT_X_START = new IExtTagParser() { + private final LineParser lineParser = new ExtLineParser(this); + private final Map> HANDLERS = new HashMap<>(); + + { + HANDLERS.put(Constants.TIME_OFFSET, new AttributeParser() { + @Override + public void parse(Attribute attribute, StartData.Builder builder, ParseState state) throws ParseException { + builder.withTimeOffset(ParseUtil.parseFloat(attribute.value, getTag())); + } + }); + + HANDLERS.put(Constants.PRECISE, new AttributeParser() { + @Override + public void parse(Attribute attribute, StartData.Builder builder, ParseState state) throws ParseException { + builder.withPrecise(ParseUtil.parseYesNo(attribute, getTag())); + } + }); + } + + @Override + public String getTag() { + return Constants.EXT_X_START_TAG; + } + + @Override + public boolean hasData() { + return true; + } + + @Override + public void parse(String line, ParseState state) throws ParseException { + if (state.startData != null) { + throw ParseException.create(ParseExceptionType.MULTIPLE_EXT_TAG_INSTANCES, getTag(), line); + } + + final StartData.Builder builder = new StartData.Builder(); + + lineParser.parse(line, state); + ParseUtil.parseAttributes(line, builder, state, HANDLERS, getTag()); + state.startData = builder.build(); + } + }; +} diff --git a/src/main/java/com/iheartradio/m3u8/ExtTagWriter.java b/src/main/java/com/iheartradio/m3u8/ExtTagWriter.java new file mode 100644 index 0000000..1c4db19 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/ExtTagWriter.java @@ -0,0 +1,96 @@ +package com.iheartradio.m3u8; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import com.iheartradio.m3u8.data.Playlist; + +abstract class ExtTagWriter implements IExtTagWriter { + + @Override + public void write(TagWriter tagWriter, Playlist playlist) throws IOException, ParseException { + if (!hasData()) { + tagWriter.writeTag(getTag()); + } + } + + abstract boolean hasData(); + + void writeAttributes(TagWriter tagWriter, T attributes, Map> attributeWriters) throws IOException, ParseException { + StringBuilder sb = new StringBuilder(); + + for(Map.Entry> entry : attributeWriters.entrySet()) { + AttributeWriter handler = entry.getValue(); + String attribute = entry.getKey(); + if (handler.containsAttribute(attributes)) { + String value = handler.write(attributes); + sb.append(attribute).append(Constants.ATTRIBUTE_SEPARATOR).append(value); + sb.append(Constants.ATTRIBUTE_LIST_SEPARATOR); + } + } + sb.deleteCharAt(sb.length() - 1); + + tagWriter.writeTag(getTag(), sb.toString()); + } + + static final IExtTagWriter EXTM3U_HANDLER = new ExtTagWriter() { + @Override + public String getTag() { + return Constants.EXTM3U_TAG; + } + + @Override + boolean hasData() { + return false; + } + }; + + static final IExtTagWriter EXT_UNKNOWN_HANDLER = new ExtTagWriter() { + @Override + public String getTag() { + return null; + } + + @Override + boolean hasData() { + return true; + } + + @Override + public void write(TagWriter tagWriter, Playlist playlist) throws IOException { + List unknownTags; + if (playlist.hasMasterPlaylist() && playlist.getMasterPlaylist().hasUnknownTags()) { + unknownTags = playlist.getMasterPlaylist().getUnknownTags(); + } else if (playlist.getMediaPlaylist().hasUnknownTags()) { + unknownTags = playlist.getMediaPlaylist().getUnknownTags(); + } else { + unknownTags = Collections.emptyList(); + } + for(String line : unknownTags) { + tagWriter.writeLine(line); + } + } + + }; + + static final IExtTagWriter EXT_X_VERSION_HANDLER = new ExtTagWriter() { + + @Override + public String getTag() { + return Constants.EXT_X_VERSION_TAG; + } + + @Override + boolean hasData() { + return true; + } + + @Override + public void write(TagWriter tagWriter, Playlist playlist) throws IOException { + tagWriter.writeTag(getTag(), Integer.toString(playlist.getCompatibilityVersion())); + } + + }; +} diff --git a/src/main/java/com/iheartradio/m3u8/ExtendedM3uParser.java b/src/main/java/com/iheartradio/m3u8/ExtendedM3uParser.java new file mode 100644 index 0000000..f27e8a0 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/ExtendedM3uParser.java @@ -0,0 +1,134 @@ +package com.iheartradio.m3u8; + +import com.iheartradio.m3u8.data.Playlist; + +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; + +class ExtendedM3uParser extends BaseM3uParser { + private final ParsingMode mParsingMode; + private final Map mExtTagParsers = new HashMap(); + + ExtendedM3uParser(InputStream inputStream, Encoding encoding, ParsingMode parsingMode) { + super(inputStream, encoding); + mParsingMode = parsingMode; + + // TODO implement the remaining EXT tag handlers and add them here + putParsers( + ExtLineParser.EXTM3U_HANDLER, + ExtLineParser.EXT_X_VERSION_HANDLER, + ExtLineParser.EXT_X_START, + MediaPlaylistLineParser.EXT_X_PLAYLIST_TYPE, + MediaPlaylistLineParser.EXT_X_PROGRAM_DATE_TIME, + MediaPlaylistLineParser.EXT_X_KEY, + MediaPlaylistLineParser.EXT_X_TARGETDURATION, + MediaPlaylistLineParser.EXT_X_MEDIA_SEQUENCE, + MediaPlaylistLineParser.EXT_X_I_FRAMES_ONLY, + MasterPlaylistLineParser.EXT_X_MEDIA, + MediaPlaylistLineParser.EXT_X_ALLOW_CACHE, + MasterPlaylistLineParser.EXT_X_STREAM_INF, + MasterPlaylistLineParser.EXT_X_I_FRAME_STREAM_INF, + MediaPlaylistLineParser.EXTINF, + MediaPlaylistLineParser.EXT_X_ENDLIST, + MediaPlaylistLineParser.EXT_X_DISCONTINUITY, + MediaPlaylistLineParser.EXT_X_MAP, + MediaPlaylistLineParser.EXT_X_BYTERANGE + ); + } + + @Override + public Playlist parse() throws IOException, ParseException, PlaylistException { + validateAvailable(); + + final ParseState state = new ParseState(mEncoding); + final LineParser playlistParser = new PlaylistLineParser(); + final LineParser trackLineParser = new TrackLineParser(); + + try { + while (mScanner.hasNext()) { + final String line = mScanner.next(); + checkWhitespace(line); + + if (line.length() == 0 || isComment(line)) { + continue; + } else { + if (isExtTag(line)) { + final String tagKey = getExtTagKey(line); + IExtTagParser tagParser = mExtTagParsers.get(tagKey); + + if (tagParser == null) { + //To support forward compatibility, when parsing Playlists, Clients + //MUST: + //o ignore any unrecognized tags. + if (mParsingMode.allowUnknownTags) { + tagParser = ExtLineParser.EXT_UNKNOWN_HANDLER; + } else { + throw ParseException.create(ParseExceptionType.UNSUPPORTED_EXT_TAG_DETECTED, tagKey, line); + } + } + + tagParser.parse(line, state); + + if (state.isMedia() && state.getMedia().endOfList) { + break; + } + } else if (state.isMaster()) { + playlistParser.parse(line, state); + } else if (state.isMedia()) { + trackLineParser.parse(line, state); + } else { + throw ParseException.create(ParseExceptionType.UNKNOWN_PLAYLIST_TYPE, line); + } + } + } + + final Playlist playlist = state.buildPlaylist(); + final PlaylistValidation validation = PlaylistValidation.from(playlist, mParsingMode); + + if (validation.isValid()) { + return playlist; + } else { + throw new PlaylistException(mScanner.getInput(), validation.getErrors()); + } + } catch (ParseException exception) { + exception.setInput(mScanner.getInput()); + throw exception; + } + } + + private void putParsers(IExtTagParser... parsers) { + if (parsers != null) { + for (IExtTagParser parser : parsers) { + mExtTagParsers.put(parser.getTag(), parser); + } + } + } + + private void checkWhitespace(final String line) throws ParseException { + if (!isComment(line)) { + if (line.length() != line.trim().length()) { + throw ParseException.create(ParseExceptionType.WHITESPACE_IN_TRACK, line); + } + } + } + + private boolean isComment(final String line) { + return line.startsWith(Constants.COMMENT_PREFIX) && !isExtTag(line); + } + + private boolean isExtTag(final String line) { + return line.startsWith(Constants.EXT_TAG_PREFIX); + } + + private String getExtTagKey(final String line) { + int index = line.indexOf(Constants.EXT_TAG_END); + + if (index == -1) { + return line.substring(1); + } else { + return line.substring(1, index); + } + } +} diff --git a/src/main/java/com/iheartradio/m3u8/ExtendedM3uWriter.java b/src/main/java/com/iheartradio/m3u8/ExtendedM3uWriter.java new file mode 100644 index 0000000..ded580b --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/ExtendedM3uWriter.java @@ -0,0 +1,46 @@ +package com.iheartradio.m3u8; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import com.iheartradio.m3u8.data.Playlist; + +class ExtendedM3uWriter extends Writer { + private List mExtTagWriter = new ArrayList(); + + public ExtendedM3uWriter(OutputStream outputStream, Encoding encoding) { + super(outputStream, encoding); + // Order influences output in file! + putWriters( + ExtTagWriter.EXTM3U_HANDLER, + ExtTagWriter.EXT_X_VERSION_HANDLER, + MediaPlaylistTagWriter.EXT_X_PLAYLIST_TYPE, + MediaPlaylistTagWriter.EXT_X_TARGETDURATION, + MediaPlaylistTagWriter.EXT_X_START, + MediaPlaylistTagWriter.EXT_X_MEDIA_SEQUENCE, + MediaPlaylistTagWriter.EXT_X_I_FRAMES_ONLY, + MasterPlaylistTagWriter.EXT_X_MEDIA, + MediaPlaylistTagWriter.EXT_X_ALLOW_CACHE, + MasterPlaylistTagWriter.EXT_X_STREAM_INF, + MasterPlaylistTagWriter.EXT_X_I_FRAME_STREAM_INF, + MediaPlaylistTagWriter.MEDIA_SEGMENTS, + MediaPlaylistTagWriter.EXT_X_ENDLIST + ); + } + + private void putWriters(SectionWriter... writers) { + if (writers != null) { + Collections.addAll(mExtTagWriter, writers); + } + } + + @Override + void doWrite(Playlist playlist) throws IOException, ParseException, PlaylistException { + for (SectionWriter singleTagWriter : mExtTagWriter) { + singleTagWriter.write(tagWriter, playlist); + } + } +} diff --git a/src/main/java/com/iheartradio/m3u8/Extension.java b/src/main/java/com/iheartradio/m3u8/Extension.java new file mode 100644 index 0000000..879a959 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/Extension.java @@ -0,0 +1,45 @@ +package com.iheartradio.m3u8; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +public enum Extension { + M3U("m3u", Encoding.WINDOWS_1252), + M3U8("m3u8", Encoding.UTF_8); + + private static final Map sMap = new HashMap(); + + static { + for (Extension mediaType : Extension.values()) { + sMap.put(mediaType.value, mediaType); + } + } + + final String value; + final Encoding encoding; + + private Extension(String value, Encoding encoding) { + this.value = value; + this.encoding = encoding; + } + + /** + * @return the extension for the given value if supported, if the extension is unsupported or null, null will be returned + */ + public static Extension fromValue(String value) { + if (value == null) { + return null; + } else { + return sMap.get(value.toLowerCase(Locale.US)); + } + } + + public String getValue() { + return value; + } + + public Encoding getEncoding() { + return encoding; + } +} diff --git a/src/main/java/com/iheartradio/m3u8/Format.java b/src/main/java/com/iheartradio/m3u8/Format.java new file mode 100644 index 0000000..9568ee2 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/Format.java @@ -0,0 +1,6 @@ +package com.iheartradio.m3u8; + +public enum Format { + M3U, + EXT_M3U; +} diff --git a/src/main/java/com/iheartradio/m3u8/IExtTagParser.java b/src/main/java/com/iheartradio/m3u8/IExtTagParser.java new file mode 100644 index 0000000..64a93aa --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/IExtTagParser.java @@ -0,0 +1,6 @@ +package com.iheartradio.m3u8; + +interface IExtTagParser extends LineParser { + String getTag(); + boolean hasData(); +} diff --git a/src/main/java/com/iheartradio/m3u8/IExtTagWriter.java b/src/main/java/com/iheartradio/m3u8/IExtTagWriter.java new file mode 100644 index 0000000..bcba086 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/IExtTagWriter.java @@ -0,0 +1,5 @@ +package com.iheartradio.m3u8; + +interface IExtTagWriter extends SectionWriter { + String getTag(); +} diff --git a/src/main/java/com/iheartradio/m3u8/IParseState.java b/src/main/java/com/iheartradio/m3u8/IParseState.java new file mode 100644 index 0000000..e0aa09c --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/IParseState.java @@ -0,0 +1,5 @@ +package com.iheartradio.m3u8; + +interface IParseState { + T buildPlaylist() throws ParseException; +} diff --git a/src/main/java/com/iheartradio/m3u8/IPlaylistParser.java b/src/main/java/com/iheartradio/m3u8/IPlaylistParser.java new file mode 100644 index 0000000..37b4969 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/IPlaylistParser.java @@ -0,0 +1,10 @@ +package com.iheartradio.m3u8; + +import com.iheartradio.m3u8.data.Playlist; + +import java.io.IOException; + +interface IPlaylistParser { + Playlist parse() throws IOException, ParseException, PlaylistException; + boolean isAvailable(); +} diff --git a/src/main/java/com/iheartradio/m3u8/LineParser.java b/src/main/java/com/iheartradio/m3u8/LineParser.java new file mode 100644 index 0000000..47ddd68 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/LineParser.java @@ -0,0 +1,5 @@ +package com.iheartradio.m3u8; + +interface LineParser { + void parse(String line, ParseState state) throws ParseException; +} diff --git a/src/main/java/com/iheartradio/m3u8/M3uParser.java b/src/main/java/com/iheartradio/m3u8/M3uParser.java new file mode 100644 index 0000000..358e4a7 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/M3uParser.java @@ -0,0 +1,65 @@ +package com.iheartradio.m3u8; + +import java.io.IOException; +import java.io.InputStream; + +import com.iheartradio.m3u8.data.MediaPlaylist; +import com.iheartradio.m3u8.data.Playlist; + +class M3uParser extends BaseM3uParser { + M3uParser(InputStream inputStream, Encoding encoding) { + super(inputStream, encoding); + } + + @Override + public Playlist parse() throws IOException, ParseException, PlaylistException { + validateAvailable(); + + final ParseState state = new ParseState(mEncoding); + final TrackLineParser trackLineParser = new TrackLineParser(); + + try { + state.setMedia(); + + while (mScanner.hasNext()) { + final String line = mScanner.next(); + validateLine(line); + + if (line.length() == 0 || isComment(line)) { + continue; + } else { + trackLineParser.parse(line, state); + } + } + + Playlist playlist = new Playlist.Builder() + .withMediaPlaylist(new MediaPlaylist.Builder() + .withTracks(state.getMedia().tracks) + .build()) + .build(); + + PlaylistValidation validation = PlaylistValidation.from(playlist); + + if (validation.isValid()) { + return playlist; + } else { + throw new PlaylistException(mScanner.getInput(), validation.getErrors()); + } + } catch (ParseException exception) { + exception.setInput(mScanner.getInput()); + throw exception; + } + } + + private void validateLine(final String line) throws ParseException { + if (!isComment(line)) { + if (line.length() != line.trim().length()) { + throw ParseException.create(ParseExceptionType.WHITESPACE_IN_TRACK, line, "" + line.length()); + } + } + } + + private boolean isComment(final String line) { + return line.indexOf(Constants.COMMENT_PREFIX) == 0; + } +} diff --git a/src/main/java/com/iheartradio/m3u8/M3uScanner.java b/src/main/java/com/iheartradio/m3u8/M3uScanner.java new file mode 100644 index 0000000..91e28c3 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/M3uScanner.java @@ -0,0 +1,41 @@ +package com.iheartradio.m3u8; + +import java.io.InputStream; +import java.util.Locale; +import java.util.Scanner; + +class M3uScanner { + private final Scanner mScanner; + private final boolean mSupportsByteOrderMark; + private final StringBuilder mInput = new StringBuilder(); + + private boolean mCheckedByteOrderMark; + + M3uScanner(InputStream inputStream, Encoding encoding) { + mScanner = new Scanner(inputStream, encoding.value).useLocale(Locale.US).useDelimiter(Constants.PARSE_NEW_LINE); + mSupportsByteOrderMark = encoding.supportsByteOrderMark; + } + + String getInput() { + return mInput.toString(); + } + + boolean hasNext() { + return mScanner.hasNext(); + } + + String next() throws ParseException { + String line = mScanner.next(); + + if (mSupportsByteOrderMark && !mCheckedByteOrderMark) { + if (!line.isEmpty() && line.charAt(0) == Constants.UNICODE_BOM) { + line = line.substring(1); + } + + mCheckedByteOrderMark = true; + } + + mInput.append(line).append("\n"); + return line; + } +} diff --git a/src/main/java/com/iheartradio/m3u8/M3uWriter.java b/src/main/java/com/iheartradio/m3u8/M3uWriter.java new file mode 100644 index 0000000..d20968d --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/M3uWriter.java @@ -0,0 +1,18 @@ +package com.iheartradio.m3u8; + +import java.io.IOException; +import java.io.OutputStream; + +import com.iheartradio.m3u8.data.Playlist; + +class M3uWriter extends Writer{ + + M3uWriter(OutputStream outputStream, Encoding encoding) { + super(outputStream, encoding); + } + + @Override + void doWrite(Playlist playlist) throws IOException { + throw new UnsupportedOperationException(); + } +} diff --git a/src/main/java/com/iheartradio/m3u8/MasterParseState.java b/src/main/java/com/iheartradio/m3u8/MasterParseState.java new file mode 100644 index 0000000..616f420 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/MasterParseState.java @@ -0,0 +1,53 @@ +package com.iheartradio.m3u8; + +import java.util.ArrayList; +import java.util.List; + +import com.iheartradio.m3u8.data.IFrameStreamInfo; +import com.iheartradio.m3u8.data.MasterPlaylist; +import com.iheartradio.m3u8.data.MediaData; +import com.iheartradio.m3u8.data.PlaylistData; +import com.iheartradio.m3u8.data.StartData; +import com.iheartradio.m3u8.data.StreamInfo; + +class MasterParseState implements PlaylistParseState { + private List mUnknownTags; + private StartData mStartData; + + public final List playlists = new ArrayList<>(); + public final List iFramePlaylists = new ArrayList<>(); + public final List mediaData = new ArrayList<>(); + + public StreamInfo streamInfo; + + public boolean isDefault; + public boolean isNotAutoSelect; + + public void clearMediaDataState() { + isDefault = false; + isNotAutoSelect = false; + } + + @Override + public PlaylistParseState setUnknownTags(final List unknownTags) { + mUnknownTags = unknownTags; + return this; + } + + @Override + public PlaylistParseState setStartData(final StartData startData) { + mStartData = startData; + return this; + } + + @Override + public MasterPlaylist buildPlaylist() throws ParseException { + return new MasterPlaylist.Builder() + .withPlaylists(playlists) + .withIFramePlaylists(iFramePlaylists) + .withMediaData(mediaData) + .withUnknownTags(mUnknownTags) + .withStartData(mStartData) + .build(); + } +} diff --git a/src/main/java/com/iheartradio/m3u8/MasterPlaylistLineParser.java b/src/main/java/com/iheartradio/m3u8/MasterPlaylistLineParser.java new file mode 100644 index 0000000..0bd5f61 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/MasterPlaylistLineParser.java @@ -0,0 +1,343 @@ +package com.iheartradio.m3u8; + +import com.iheartradio.m3u8.data.*; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +class MasterPlaylistLineParser implements LineParser { + private final IExtTagParser mTagParser; + private final LineParser mLineParser; + + MasterPlaylistLineParser(IExtTagParser parser) { + this(parser, new ExtLineParser(parser)); + } + + MasterPlaylistLineParser(IExtTagParser tagParser, LineParser lineParser) { + mTagParser = tagParser; + mLineParser = lineParser; + } + + @Override + public void parse(String line, ParseState state) throws ParseException { + if (state.isMedia()) { + throw ParseException.create(ParseExceptionType.MASTER_IN_MEDIA, mTagParser.getTag()); + } + + state.setMaster(); + mLineParser.parse(line, state); + } + + // master playlist tags + + static final IExtTagParser EXT_X_MEDIA = new IExtTagParser() { + private final LineParser mLineParser = new MasterPlaylistLineParser(this); + private final Map> HANDLERS = new HashMap>(); + + { + HANDLERS.put(Constants.TYPE, new AttributeParser() { + @Override + public void parse(Attribute attribute, MediaData.Builder builder, ParseState state) throws ParseException { + final MediaType type = MediaType.fromValue(attribute.value); + + if (type == null) { + throw ParseException.create(ParseExceptionType.INVALID_MEDIA_TYPE, getTag(), attribute.toString()); + } else { + builder.withType(type); + } + } + }); + + HANDLERS.put(Constants.URI, new AttributeParser() { + @Override + public void parse(Attribute attribute, MediaData.Builder builder, ParseState state) throws ParseException { + builder.withUri(ParseUtil.decodeUri(ParseUtil.parseQuotedString(attribute.value, getTag()), state.encoding)); + } + }); + + HANDLERS.put(Constants.GROUP_ID, new AttributeParser() { + @Override + public void parse(Attribute attribute, MediaData.Builder builder, ParseState state) throws ParseException { + final String groupId = ParseUtil.parseQuotedString(attribute.value, getTag()); + + if (groupId.isEmpty()) { + throw ParseException.create(ParseExceptionType.EMPTY_MEDIA_GROUP_ID, getTag(), attribute.toString()); + } else { + builder.withGroupId(groupId); + } + } + }); + + HANDLERS.put(Constants.LANGUAGE, new AttributeParser() { + @Override + public void parse(Attribute attribute, MediaData.Builder builder, ParseState state) throws ParseException { + builder.withLanguage(ParseUtil.parseQuotedString(attribute.value, getTag())); + } + }); + + HANDLERS.put(Constants.ASSOCIATED_LANGUAGE, new AttributeParser() { + @Override + public void parse(Attribute attribute, MediaData.Builder builder, ParseState state) throws ParseException { + builder.withAssociatedLanguage(ParseUtil.parseQuotedString(attribute.value, getTag())); + } + }); + + HANDLERS.put(Constants.NAME, new AttributeParser() { + @Override + public void parse(Attribute attribute, MediaData.Builder builder, ParseState state) throws ParseException { + final String name = ParseUtil.parseQuotedString(attribute.value, getTag()); + + if (name.isEmpty()) { + throw ParseException.create(ParseExceptionType.EMPTY_MEDIA_NAME, getTag(), attribute.toString()); + } else { + builder.withName(name); + } + } + }); + + HANDLERS.put(Constants.DEFAULT, new AttributeParser() { + @Override + public void parse(Attribute attribute, MediaData.Builder builder, ParseState state) throws ParseException { + boolean isDefault = ParseUtil.parseYesNo(attribute, getTag()); + + builder.withDefault(isDefault); + state.getMaster().isDefault = isDefault; + + if (isDefault) { + if (state.getMaster().isNotAutoSelect) { + throw ParseException.create(ParseExceptionType.AUTO_SELECT_DISABLED_FOR_DEFAULT, getTag(), attribute.toString()); + } + + builder.withAutoSelect(true); + } + } + }); + + HANDLERS.put(Constants.AUTO_SELECT, new AttributeParser() { + @Override + public void parse(Attribute attribute, MediaData.Builder builder, ParseState state) throws ParseException { + final boolean isAutoSelect = ParseUtil.parseYesNo(attribute, getTag()); + + builder.withAutoSelect(isAutoSelect); + state.getMaster().isNotAutoSelect = !isAutoSelect; + + if (state.getMaster().isDefault && !isAutoSelect) { + throw ParseException.create(ParseExceptionType.AUTO_SELECT_DISABLED_FOR_DEFAULT, getTag(), attribute.toString()); + } + } + }); + + HANDLERS.put(Constants.FORCED, new AttributeParser() { + @Override + public void parse(Attribute attribute, MediaData.Builder builder, ParseState state) throws ParseException { + builder.withForced(ParseUtil.parseYesNo(attribute, getTag())); + } + }); + + HANDLERS.put(Constants.IN_STREAM_ID, new AttributeParser() { + @Override + public void parse(Attribute attribute, MediaData.Builder builder, ParseState state) throws ParseException { + final String inStreamId = ParseUtil.parseQuotedString(attribute.value, getTag()); + + if (Constants.EXT_X_MEDIA_IN_STREAM_ID_PATTERN.matcher(inStreamId).matches()) { + builder.withInStreamId(inStreamId); + } else { + throw ParseException.create(ParseExceptionType.INVALID_MEDIA_IN_STREAM_ID, getTag(), attribute.toString()); + } + } + }); + + HANDLERS.put(Constants.CHARACTERISTICS, new AttributeParser() { + @Override + public void parse(Attribute attribute, MediaData.Builder builder, ParseState state) throws ParseException { + final String[] characteristicStrings = ParseUtil.parseQuotedString(attribute.value, getTag()).split(Constants.COMMA); + + if (characteristicStrings.length == 0) { + throw ParseException.create(ParseExceptionType.EMPTY_MEDIA_CHARACTERISTICS, getTag(), attribute.toString()); + } else { + builder.withCharacteristics(Arrays.asList(characteristicStrings)); + } + } + }); + + HANDLERS.put(Constants.CHANNELS, new AttributeParser() { + @Override + public void parse(Attribute attribute, MediaData.Builder builder, ParseState state) throws ParseException { + final String[] channelsStrings = ParseUtil.parseQuotedString(attribute.value, getTag()).split(Constants.LIST_SEPARATOR); + + if (channelsStrings.length == 0 || channelsStrings[0].isEmpty()) { + throw ParseException.create(ParseExceptionType.EMPTY_MEDIA_CHANNELS, getTag(), attribute.toString()); + } else { + final int channelsCount = ParseUtil.parseInt(channelsStrings[0], getTag()); + builder.withChannels(channelsCount); + } + } + }); + } + + @Override + public String getTag() { + return Constants.EXT_X_MEDIA_TAG; + } + + @Override + public boolean hasData() { + return true; + } + + @Override + public void parse(String line, ParseState state) throws ParseException { + mLineParser.parse(line, state); + + final MediaData.Builder builder = new MediaData.Builder(); + + state.getMaster().clearMediaDataState(); + ParseUtil.parseAttributes(line, builder, state, HANDLERS, getTag()); + state.getMaster().mediaData.add(builder.build()); + } + }; + + static final IExtTagParser EXT_X_I_FRAME_STREAM_INF = new IExtTagParser() { + private final LineParser mLineParser = new MasterPlaylistLineParser(this); + private final Map> HANDLERS = makeExtStreamInfHandlers(getTag()); + + { + HANDLERS.put(Constants.URI, new AttributeParser() { + @Override + public void parse(Attribute attribute, IFrameStreamInfo.Builder builder, ParseState state) throws ParseException { + builder.withUri(ParseUtil.parseQuotedString(attribute.value, getTag())); + } + }); + } + + @Override + public String getTag() { + return Constants.EXT_X_I_FRAME_STREAM_INF_TAG; + } + + @Override + public boolean hasData() { + return true; + } + + @Override + public void parse(String line, ParseState state) throws ParseException { + mLineParser.parse(line, state); + + final IFrameStreamInfo.Builder builder = new IFrameStreamInfo.Builder(); + + ParseUtil.parseAttributes(line, builder, state, HANDLERS, getTag()); + state.getMaster().iFramePlaylists.add(builder.build()); + } + }; + + static final IExtTagParser EXT_X_STREAM_INF = new IExtTagParser() { + private final LineParser mLineParser = new MasterPlaylistLineParser(this); + private final Map> HANDLERS = makeExtStreamInfHandlers(getTag()); + + { + HANDLERS.put(Constants.AUDIO, new AttributeParser() { + @Override + public void parse(Attribute attribute, StreamInfo.Builder builder, ParseState state) throws ParseException { + builder.withAudio(ParseUtil.parseQuotedString(attribute.value, getTag())); + } + }); + + HANDLERS.put(Constants.SUBTITLES, new AttributeParser() { + @Override + public void parse(Attribute attribute, StreamInfo.Builder builder, ParseState state) throws ParseException { + builder.withSubtitles(ParseUtil.parseQuotedString(attribute.value, getTag())); + } + }); + + HANDLERS.put(Constants.CLOSED_CAPTIONS, new AttributeParser() { + @Override + public void parse(Attribute attribute, StreamInfo.Builder builder, ParseState state) throws ParseException { + if (!attribute.value.equals(Constants.NO_CLOSED_CAPTIONS)) { + builder.withClosedCaptions(ParseUtil.parseQuotedString(attribute.value, getTag())); + } + } + }); + } + + @Override + public String getTag() { + return Constants.EXT_X_STREAM_INF_TAG; + } + + @Override + public boolean hasData() { + return true; + } + + @Override + public void parse(String line, ParseState state) throws ParseException { + mLineParser.parse(line, state); + + final StreamInfo.Builder builder = new StreamInfo.Builder(); + + ParseUtil.parseAttributes(line, builder, state, HANDLERS, getTag()); + state.getMaster().streamInfo = builder.build(); + } + }; + + static Map> makeExtStreamInfHandlers(final String tag) { + final Map> handlers = new HashMap<>(); + + handlers.put(Constants.BANDWIDTH, new AttributeParser() { + @Override + public void parse(Attribute attribute, T builder, ParseState state) throws ParseException { + builder.withBandwidth(ParseUtil.parseInt(attribute.value, tag)); + } + }); + + handlers.put(Constants.AVERAGE_BANDWIDTH, new AttributeParser() { + @Override + public void parse(Attribute attribute, T builder, ParseState state) throws ParseException { + builder.withAverageBandwidth(ParseUtil.parseInt(attribute.value, tag)); + } + }); + + handlers.put(Constants.CODECS, new AttributeParser() { + @Override + public void parse(Attribute attribute, T builder, ParseState state) throws ParseException { + final String[] characteristicStrings = ParseUtil.parseQuotedString(attribute.value, tag).split(Constants.COMMA); + + if (characteristicStrings.length > 0) { + builder.withCodecs(Arrays.asList(characteristicStrings)); + } + } + }); + + handlers.put(Constants.RESOLUTION, new AttributeParser() { + @Override + public void parse(Attribute attribute, T builder, ParseState state) throws ParseException { + builder.withResolution(ParseUtil.parseResolution(attribute.value, tag)); + } + }); + + handlers.put(Constants.FRAME_RATE, new AttributeParser() { + @Override + public void parse(Attribute attribute, T builder, ParseState state) throws ParseException { + builder.withFrameRate(ParseUtil.parseFloat(attribute.value, tag)); + } + }); + + handlers.put(Constants.VIDEO, new AttributeParser() { + @Override + public void parse(Attribute attribute, T builder, ParseState state) throws ParseException { + builder.withVideo(ParseUtil.parseQuotedString(attribute.value, tag)); + } + }); + + handlers.put(Constants.PROGRAM_ID, new AttributeParser() { + @Override + public void parse(Attribute attribute, T builder, ParseState state) throws ParseException { + // deprecated + } + }); + + return handlers; + } +} diff --git a/src/main/java/com/iheartradio/m3u8/MasterPlaylistTagWriter.java b/src/main/java/com/iheartradio/m3u8/MasterPlaylistTagWriter.java new file mode 100644 index 0000000..5720213 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/MasterPlaylistTagWriter.java @@ -0,0 +1,372 @@ +package com.iheartradio.m3u8; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.iheartradio.m3u8.data.IFrameStreamInfo; +import com.iheartradio.m3u8.data.IStreamInfo; +import com.iheartradio.m3u8.data.MasterPlaylist; +import com.iheartradio.m3u8.data.MediaData; +import com.iheartradio.m3u8.data.Playlist; +import com.iheartradio.m3u8.data.PlaylistData; +import com.iheartradio.m3u8.data.StreamInfo; + +abstract class MasterPlaylistTagWriter extends ExtTagWriter { + + @Override + public final void write(TagWriter tagWriter, Playlist playlist) throws IOException, ParseException { + if (playlist.hasMasterPlaylist()) { + doWrite(tagWriter, playlist, playlist.getMasterPlaylist()); + } + } + + public void doWrite(TagWriter tagWriter,Playlist playlist, MasterPlaylist masterPlaylist) throws IOException, ParseException { + tagWriter.writeTag(getTag()); + } + + // master playlist tags + + static final IExtTagWriter EXT_X_MEDIA = new MasterPlaylistTagWriter() { + private final Map> HANDLERS = new HashMap>(); + + { + HANDLERS.put(Constants.TYPE, new AttributeWriter() { + @Override + public boolean containsAttribute(MediaData mediaData) { + return true; + }; + + @Override + public String write(MediaData mediaData) throws ParseException { + return mediaData.getType().getValue(); + } + }); + + HANDLERS.put(Constants.URI, new AttributeWriter() { + @Override + public boolean containsAttribute(MediaData mediaData) { + return mediaData.hasUri(); + }; + + @Override + public String write(MediaData mediaData) throws ParseException { + return WriteUtil.writeQuotedString(mediaData.getUri(), getTag()); + } + }); + + HANDLERS.put(Constants.GROUP_ID, new AttributeWriter() { + @Override + public boolean containsAttribute(MediaData mediaData) { + return true; + }; + + @Override + public String write(MediaData mediaData) throws ParseException { + return WriteUtil.writeQuotedString(mediaData.getGroupId(), getTag()); + } + + }); + + HANDLERS.put(Constants.LANGUAGE, new AttributeWriter() { + @Override + public boolean containsAttribute(MediaData mediaData) { + return mediaData.hasLanguage(); + }; + + @Override + public String write(MediaData mediaData) throws ParseException { + return WriteUtil.writeQuotedString(mediaData.getLanguage(), getTag()); + } + }); + + HANDLERS.put(Constants.ASSOCIATED_LANGUAGE, new AttributeWriter() { + @Override + public boolean containsAttribute(MediaData mediaData) { + return mediaData.hasAssociatedLanguage(); + }; + + @Override + public String write(MediaData mediaData) throws ParseException { + return WriteUtil.writeQuotedString(mediaData.getAssociatedLanguage(), getTag()); + } + + }); + + HANDLERS.put(Constants.NAME, new AttributeWriter() { + @Override + public boolean containsAttribute(MediaData mediaData) { + return true; + }; + + @Override + public String write(MediaData mediaData) throws ParseException { + return WriteUtil.writeQuotedString(mediaData.getName(), getTag()); + } + }); + + HANDLERS.put(Constants.DEFAULT, new AttributeWriter() { + @Override + public boolean containsAttribute(MediaData mediaData) { + return true; + } + + @Override + public String write(MediaData mediaData) throws ParseException { + return WriteUtil.writeYesNo(mediaData.isDefault()); + } + }); + + HANDLERS.put(Constants.AUTO_SELECT, new AttributeWriter() { + @Override + public boolean containsAttribute(MediaData mediaData) { + return true; + } + + @Override + public String write(MediaData mediaData) throws ParseException { + return WriteUtil.writeYesNo(mediaData.isAutoSelect()); + } + }); + + HANDLERS.put(Constants.FORCED, new AttributeWriter() { + @Override + public boolean containsAttribute(MediaData mediaData) { + return true; + } + + @Override + public String write(MediaData mediaData) throws ParseException { + return WriteUtil.writeYesNo(mediaData.isForced()); + } + }); + + HANDLERS.put(Constants.IN_STREAM_ID, new AttributeWriter() { + @Override + public boolean containsAttribute(MediaData mediaData) { + return mediaData.hasInStreamId(); + } + + @Override + public String write(MediaData mediaData) throws ParseException { + return WriteUtil.writeQuotedString(mediaData.getInStreamId(), getTag()); + } + }); + + HANDLERS.put(Constants.CHARACTERISTICS, new AttributeWriter() { + @Override + public boolean containsAttribute(MediaData mediaData) { + return mediaData.hasCharacteristics(); + } + + @Override + public String write(MediaData mediaData) throws ParseException { + return WriteUtil.writeQuotedString(WriteUtil.join(mediaData.getCharacteristics(), Constants.COMMA), getTag()); + } + }); + } + + @Override + public String getTag() { + return Constants.EXT_X_MEDIA_TAG; + } + + @Override + boolean hasData() { + return true; + } + + @Override + public void doWrite(TagWriter tagWriter, Playlist playlist, MasterPlaylist masterPlaylist) throws IOException, ParseException { + if (masterPlaylist.getMediaData().size() > 0) { + List mds = masterPlaylist.getMediaData(); + for(MediaData md : mds) { + writeAttributes(tagWriter, md, HANDLERS); + } + } + } + }; + + static abstract class EXT_STREAM_INF extends MasterPlaylistTagWriter { + final Map> HANDLERS = new HashMap<>(); + + { + HANDLERS.put(Constants.BANDWIDTH, new AttributeWriter() { + @Override + public boolean containsAttribute(T streamInfo) { + return true; + } + + @Override + public String write(T streamInfo) { + return Integer.toString(streamInfo.getBandwidth()); + } + }); + + HANDLERS.put(Constants.AVERAGE_BANDWIDTH, new AttributeWriter() { + @Override + public boolean containsAttribute(T streamInfo) { + return streamInfo.hasAverageBandwidth(); + } + + @Override + public String write(T streamInfo) { + return Integer.toString(streamInfo.getAverageBandwidth()); + } + }); + + HANDLERS.put(Constants.CODECS, new AttributeWriter() { + @Override + public boolean containsAttribute(T streamInfo) { + return streamInfo.hasCodecs(); + } + + @Override + public String write(T streamInfo) throws ParseException { + return WriteUtil.writeQuotedString(WriteUtil.join(streamInfo.getCodecs(), Constants.COMMA), getTag()); + } + }); + + HANDLERS.put(Constants.RESOLUTION, new AttributeWriter() { + @Override + public boolean containsAttribute(T streamInfo) { + return streamInfo.hasResolution(); + } + + @Override + public String write(T streamInfo) throws ParseException { + return WriteUtil.writeResolution(streamInfo.getResolution()); + } + }); + + HANDLERS.put(Constants.FRAME_RATE, new AttributeWriter() { + @Override + public boolean containsAttribute(T streamInfo) { + return streamInfo.hasFrameRate(); + } + + @Override + public String write(T streamInfo) throws ParseException { + return String.valueOf(streamInfo.getFrameRate()); + } + }); + + HANDLERS.put(Constants.VIDEO, new AttributeWriter() { + @Override + public boolean containsAttribute(T streamInfo) { + return streamInfo.hasVideo(); + } + + @Override + public String write(T streamInfo) throws ParseException { + return WriteUtil.writeQuotedString(streamInfo.getVideo(), getTag()); + } + }); + + HANDLERS.put(Constants.PROGRAM_ID, new AttributeWriter() { + @Override + public boolean containsAttribute(T streamInfo) { + return false; + } + + @Override + public String write(T streamInfo) { + // deprecated + return ""; + } + }); + } + + @Override + boolean hasData() { + return true; + } + + public abstract void doWrite(TagWriter tagWriter, Playlist playlist, MasterPlaylist masterPlaylist) throws IOException, ParseException; + } + + static final IExtTagWriter EXT_X_I_FRAME_STREAM_INF = new EXT_STREAM_INF() { + + { + HANDLERS.put(Constants.URI, new AttributeWriter() { + @Override + public boolean containsAttribute(IFrameStreamInfo streamInfo) { + return true; + }; + + @Override + public String write(IFrameStreamInfo streamInfo) throws ParseException { + return WriteUtil.writeQuotedString(streamInfo.getUri(), getTag()); + } + }); + } + + @Override + public String getTag() { + return Constants.EXT_X_I_FRAME_STREAM_INF_TAG; + } + + @Override + public void doWrite(TagWriter tagWriter, Playlist playlist, MasterPlaylist masterPlaylist) throws IOException, ParseException { + for(IFrameStreamInfo streamInfo : masterPlaylist.getIFramePlaylists()) { + writeAttributes(tagWriter, streamInfo, HANDLERS); + } + } + }; + + static final IExtTagWriter EXT_X_STREAM_INF = new EXT_STREAM_INF() { + + { + HANDLERS.put(Constants.AUDIO, new AttributeWriter() { + @Override + public boolean containsAttribute(StreamInfo streamInfo) { + return streamInfo.hasAudio(); + } + + @Override + public String write(StreamInfo streamInfo) throws ParseException { + return WriteUtil.writeQuotedString(streamInfo.getAudio(), getTag()); + } + }); + + HANDLERS.put(Constants.SUBTITLES, new AttributeWriter() { + @Override + public boolean containsAttribute(StreamInfo streamInfo) { + return streamInfo.hasSubtitles(); + } + + @Override + public String write(StreamInfo streamInfo) throws ParseException { + return WriteUtil.writeQuotedString(streamInfo.getSubtitles(), getTag()); + } + }); + + HANDLERS.put(Constants.CLOSED_CAPTIONS, new AttributeWriter() { + @Override + public boolean containsAttribute(StreamInfo streamInfo) { + return streamInfo.hasClosedCaptions(); + } + + public String write(StreamInfo streamInfo) throws ParseException { + return WriteUtil.writeQuotedString(streamInfo.getClosedCaptions(), getTag()); + } + }); + } + + @Override + public String getTag() { + return Constants.EXT_X_STREAM_INF_TAG; + } + + @Override + public void doWrite(TagWriter tagWriter, Playlist playlist, MasterPlaylist masterPlaylist) throws IOException, ParseException { + for(PlaylistData playlistData : masterPlaylist.getPlaylists()) { + if (playlistData.hasStreamInfo()) { + writeAttributes(tagWriter, playlistData.getStreamInfo(), HANDLERS); + tagWriter.writeLine(playlistData.getUri()); + } + } + } + }; +} diff --git a/src/main/java/com/iheartradio/m3u8/MediaParseState.java b/src/main/java/com/iheartradio/m3u8/MediaParseState.java new file mode 100644 index 0000000..507d106 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/MediaParseState.java @@ -0,0 +1,63 @@ +package com.iheartradio.m3u8; + +import com.iheartradio.m3u8.data.*; + +import java.util.ArrayList; +import java.util.List; + +class MediaParseState implements PlaylistParseState { + private List mUnknownTags; + private StartData mStartData; + + public final List tracks = new ArrayList<>(); + + public Integer targetDuration; + public Integer mediaSequenceNumber; + public boolean isIframesOnly; + public PlaylistType playlistType; + public TrackInfo trackInfo; + public EncryptionData encryptionData; + public String programDateTime; + public boolean endOfList; + public boolean hasDiscontinuity; + public MapInfo mapInfo; + public ByteRange byteRange; + + @Override + public PlaylistParseState setUnknownTags(final List unknownTags) { + mUnknownTags = unknownTags; + return this; + } + + @Override + public PlaylistParseState setStartData(final StartData startData) { + mStartData = startData; + return this; + } + + @Override + public MediaPlaylist buildPlaylist() throws ParseException { + return new MediaPlaylist.Builder() + .withTracks(tracks) + .withUnknownTags(mUnknownTags) + .withTargetDuration(targetDuration == null ? maximumDuration(tracks, 0) : targetDuration) + .withIsIframesOnly(isIframesOnly) + .withStartData(mStartData) + .withIsOngoing(!endOfList) + .withMediaSequenceNumber(mediaSequenceNumber == null ? 0 : mediaSequenceNumber) + .withPlaylistType(playlistType) + .build(); + } + + private static int maximumDuration(List tracks, float minValue) { + float max = minValue; + + for (final TrackData trackData : tracks) { + if (trackData.hasTrackInfo()) { + max = Math.max(max, trackData.getTrackInfo().duration); + } + } + + return 0; + } +} diff --git a/src/main/java/com/iheartradio/m3u8/MediaPlaylistLineParser.java b/src/main/java/com/iheartradio/m3u8/MediaPlaylistLineParser.java new file mode 100644 index 0000000..5dbaf5f --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/MediaPlaylistLineParser.java @@ -0,0 +1,466 @@ +package com.iheartradio.m3u8; + +import com.iheartradio.m3u8.data.*; +import com.iheartradio.m3u8.data.EncryptionData.Builder; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; + +class MediaPlaylistLineParser implements LineParser { + private final IExtTagParser tagParser; + private final LineParser lineParser; + + MediaPlaylistLineParser(IExtTagParser parser) { + this(parser, new ExtLineParser(parser)); + } + + MediaPlaylistLineParser(IExtTagParser tagParser, LineParser lineParser) { + this.tagParser = tagParser; + this.lineParser = lineParser; + } + + @Override + public void parse(String line, ParseState state) throws ParseException { + if (state.isMaster()) { + throw ParseException.create(ParseExceptionType.MEDIA_IN_MASTER, tagParser.getTag()); + } + + state.setMedia(); + lineParser.parse(line, state); + } + + // media playlist tags + + static final IExtTagParser EXT_X_ENDLIST = new IExtTagParser() { + private final LineParser lineParser = new MediaPlaylistLineParser(this); + + @Override + public String getTag() { + return Constants.EXT_X_ENDLIST_TAG; + } + + @Override + public boolean hasData() { + return false; + } + + @Override + public void parse(String line, ParseState state) throws ParseException { + lineParser.parse(line, state); + + ParseUtil.match(Constants.EXT_X_ENDLIST_PATTERN, line, getTag()); + state.getMedia().endOfList = true; + } + }; + + static final IExtTagParser EXT_X_I_FRAMES_ONLY = new IExtTagParser() { + private final LineParser lineParser = new MediaPlaylistLineParser(this); + + @Override + public String getTag() { + return Constants.EXT_X_I_FRAMES_ONLY_TAG; + } + + @Override + public boolean hasData() { + return false; + } + + @Override + public void parse(String line, ParseState state) throws ParseException { + lineParser.parse(line, state); + + ParseUtil.match(Constants.EXT_X_I_FRAMES_ONLY_PATTERN, line, getTag()); + + if (state.getCompatibilityVersion() < 4) { + throw ParseException.create(ParseExceptionType.REQUIRES_PROTOCOL_VERSION_4_OR_HIGHER, getTag()); + } + + state.setIsIframesOnly(); + } + }; + + static final IExtTagParser EXT_X_PLAYLIST_TYPE = new IExtTagParser() { + private final LineParser lineParser = new MediaPlaylistLineParser(this); + + @Override + public String getTag() { + return Constants.EXT_X_PLAYLIST_TYPE_TAG; + } + + @Override + public boolean hasData() { + return true; + } + + @Override + public void parse(String line, ParseState state) throws ParseException { + lineParser.parse(line, state); + + final Matcher matcher = ParseUtil.match(Constants.EXT_X_PLAYLIST_TYPE_PATTERN, line, getTag()); + + if (state.getMedia().playlistType != null) { + throw ParseException.create(ParseExceptionType.MULTIPLE_EXT_TAG_INSTANCES, getTag(), line); + } + + state.getMedia().playlistType = ParseUtil.parseEnum(matcher.group(1), PlaylistType.class, getTag()); + } + }; + + + static final IExtTagParser EXT_X_PROGRAM_DATE_TIME = new IExtTagParser() { + private final LineParser lineParser = new MediaPlaylistLineParser(this); + + @Override + public String getTag() { + return Constants.EXT_X_PROGRAM_DATE_TIME_TAG; + } + + @Override + public boolean hasData() { + return true; + } + + @Override + public void parse(String line, ParseState state) throws ParseException { + lineParser.parse(line, state); + + final Matcher matcher = ParseUtil.match(Constants.EXT_X_PROGRAM_DATE_TIME_PATTERN, line, getTag()); + + if (state.getMedia().programDateTime != null) { + throw ParseException.create(ParseExceptionType.MULTIPLE_EXT_TAG_INSTANCES, getTag(), line); + } + + state.getMedia().programDateTime = ParseUtil.parseDateTime(line,getTag()); + } + }; + + static final IExtTagParser EXT_X_START = new IExtTagParser() { + private final LineParser lineParser = new MediaPlaylistLineParser(this); + private final Map> HANDLERS = new HashMap<>(); + + { + HANDLERS.put(Constants.TIME_OFFSET, new AttributeParser() { + @Override + public void parse(Attribute attribute, StartData.Builder builder, ParseState state) throws ParseException { + builder.withTimeOffset(ParseUtil.parseFloat(attribute.value, getTag())); + } + }); + + HANDLERS.put(Constants.PRECISE, new AttributeParser() { + @Override + public void parse(Attribute attribute, StartData.Builder builder, ParseState state) throws ParseException { + builder.withPrecise(ParseUtil.parseYesNo(attribute, getTag())); + } + }); + } + + @Override + public String getTag() { + return Constants.EXT_X_START_TAG; + } + + @Override + public boolean hasData() { + return true; + } + + @Override + public void parse(String line, ParseState state) throws ParseException { + lineParser.parse(line, state); + + final StartData.Builder builder = new StartData.Builder(); + ParseUtil.parseAttributes(line, builder, state, HANDLERS, getTag()); + final StartData startData = builder.build(); + + state.getMedia().setStartData(startData); + } + }; + + + static final IExtTagParser EXT_X_TARGETDURATION = new IExtTagParser() { + private final LineParser lineParser = new MediaPlaylistLineParser(this); + + @Override + public String getTag() { + return Constants.EXT_X_TARGETDURATION_TAG; + } + + @Override + public boolean hasData() { + return true; + } + + @Override + public void parse(String line, ParseState state) throws ParseException { + lineParser.parse(line, state); + + final Matcher matcher = ParseUtil.match(Constants.EXT_X_TARGETDURATION_PATTERN, line, getTag()); + + if (state.getMedia().targetDuration != null) { + throw ParseException.create(ParseExceptionType.MULTIPLE_EXT_TAG_INSTANCES, getTag(), line); + } + + state.getMedia().targetDuration = ParseUtil.parseInt(matcher.group(1), getTag()); + } + }; + + static final IExtTagParser EXT_X_MEDIA_SEQUENCE = new IExtTagParser() { + private final LineParser lineParser = new MediaPlaylistLineParser(this); + + @Override + public String getTag() { + return Constants.EXT_X_MEDIA_SEQUENCE_TAG; + } + + @Override + public boolean hasData() { + return true; + } + + @Override + public void parse(String line, ParseState state) throws ParseException { + lineParser.parse(line, state); + + final Matcher matcher = ParseUtil.match(Constants.EXT_X_MEDIA_SEQUENCE_PATTERN, line, getTag()); + + if (state.getMedia().mediaSequenceNumber != null) { + throw ParseException.create(ParseExceptionType.MULTIPLE_EXT_TAG_INSTANCES, getTag(), line); + } + + state.getMedia().mediaSequenceNumber = ParseUtil.parseInt(matcher.group(1), getTag()); + } + }; + + static final IExtTagParser EXT_X_ALLOW_CACHE = new IExtTagParser() { + private final LineParser lineParser = new MediaPlaylistLineParser(this); + + @Override + public String getTag() { + return Constants.EXT_X_ALLOW_CACHE_TAG; + } + + @Override + public boolean hasData() { + return true; + } + + @Override + public void parse(String line, ParseState state) throws ParseException { + lineParser.parse(line, state); + + // deprecated + } + }; + + // media segment tags + + static final IExtTagParser EXTINF = new IExtTagParser() { + private final LineParser lineParser = new MediaPlaylistLineParser(this); + + @Override + public String getTag() { + return Constants.EXTINF_TAG; + } + + @Override + public boolean hasData() { + return true; + } + + @Override + public void parse(String line, ParseState state) throws ParseException { + lineParser.parse(line, state); + + final Matcher matcher = ParseUtil.match(Constants.EXTINF_PATTERN, line, getTag()); + + state.getMedia().trackInfo = new TrackInfo(ParseUtil.parseFloat(matcher.group(1), getTag()), matcher.group(2)); + } + }; + + static final IExtTagParser EXT_X_DISCONTINUITY = new IExtTagParser() { + private final LineParser lineParser = new MediaPlaylistLineParser(this); + + @Override + public String getTag() { + return Constants.EXT_X_DISCONTINUITY_TAG; + } + + @Override + public boolean hasData() { + return false; + } + + @Override + public void parse(String line, ParseState state) throws ParseException { + lineParser.parse(line, state); + final Matcher matcher = ParseUtil.match(Constants.EXT_X_DISCONTINUITY_PATTERN, line, getTag()); + state.getMedia().hasDiscontinuity = true; + } + }; + + static final IExtTagParser EXT_X_KEY = new IExtTagParser() { + private final LineParser lineParser = new MediaPlaylistLineParser(this); + private final Map> HANDLERS = new HashMap<>(); + + { + HANDLERS.put(Constants.METHOD, new AttributeParser() { + @Override + public void parse(Attribute attribute, Builder builder, ParseState state) throws ParseException { + final EncryptionMethod method = EncryptionMethod.fromValue(attribute.value); + + if (method == null) { + throw ParseException.create(ParseExceptionType.INVALID_ENCRYPTION_METHOD, getTag(), attribute.toString()); + } else { + builder.withMethod(method); + } + } + }); + + HANDLERS.put(Constants.URI, new AttributeParser() { + @Override + public void parse(Attribute attribute, Builder builder, ParseState state) throws ParseException { + builder.withUri(ParseUtil.decodeUri(ParseUtil.parseQuotedString(attribute.value, getTag()), state.encoding)); + } + }); + + HANDLERS.put(Constants.IV, new AttributeParser() { + @Override + public void parse(Attribute attribute, Builder builder, ParseState state) throws ParseException { + final List initializationVector = ParseUtil.parseHexadecimal(attribute.value, getTag()); + + if ((initializationVector.size() != Constants.IV_SIZE) && + (initializationVector.size() != Constants.IV_SIZE_ALTERNATIVE)) { + throw ParseException.create(ParseExceptionType.INVALID_IV_SIZE, getTag(), attribute.toString()); + } + + builder.withInitializationVector(initializationVector); + } + }); + + HANDLERS.put(Constants.KEY_FORMAT, new AttributeParser() { + @Override + public void parse(Attribute attribute, Builder builder, ParseState state) throws ParseException { + builder.withKeyFormat(ParseUtil.parseQuotedString(attribute.value, getTag())); + } + }); + + HANDLERS.put(Constants.KEY_FORMAT_VERSIONS, new AttributeParser() { + @Override + public void parse(Attribute attribute, Builder builder, ParseState state) throws ParseException { + final String[] versionStrings = ParseUtil.parseQuotedString(attribute.value, getTag()).split(Constants.LIST_SEPARATOR); + final List versions = new ArrayList<>(); + + for (String version : versionStrings) { + try { + versions.add(Integer.parseInt(version)); + } catch (NumberFormatException exception) { + throw ParseException.create(ParseExceptionType.INVALID_KEY_FORMAT_VERSIONS, getTag(), attribute.toString()); + } + } + + builder.withKeyFormatVersions(versions); + } + }); + } + + @Override + public String getTag() { + return Constants.EXT_X_KEY_TAG; + } + + @Override + public boolean hasData() { + return true; + } + + @Override + public void parse(String line, ParseState state) throws ParseException { + lineParser.parse(line, state); + + final EncryptionData.Builder builder = new EncryptionData.Builder() + .withKeyFormat(Constants.DEFAULT_KEY_FORMAT) + .withKeyFormatVersions(Constants.DEFAULT_KEY_FORMAT_VERSIONS); + + ParseUtil.parseAttributes(line, builder, state, HANDLERS, getTag()); + + final EncryptionData encryptionData = builder.build(); + + if (encryptionData.getMethod() != EncryptionMethod.NONE && encryptionData.getUri() == null) { + throw ParseException.create(ParseExceptionType.MISSING_ENCRYPTION_URI, getTag(), line); + } + + state.getMedia().encryptionData = encryptionData; + } + }; + + static final IExtTagParser EXT_X_MAP = new IExtTagParser() { + private final LineParser lineParser = new MediaPlaylistLineParser(this); + private final Map> HANDLERS = new HashMap<>(); + + { + HANDLERS.put(Constants.URI, new AttributeParser() { + @Override + public void parse(Attribute attribute, MapInfo.Builder builder, ParseState state) throws ParseException { + builder.withUri(ParseUtil.decodeUri(ParseUtil.parseQuotedString(attribute.value, getTag()), state.encoding)); + } + }); + + HANDLERS.put(Constants.BYTERANGE, new AttributeParser() { + @Override + public void parse(Attribute attribute, MapInfo.Builder builder, ParseState state) throws ParseException { + Matcher matcher = Constants.EXT_X_BYTERANGE_VALUE_PATTERN.matcher(ParseUtil.parseQuotedString(attribute.value, getTag())); + if (!matcher.matches()) { + throw ParseException.create(ParseExceptionType.INVALID_BYTERANGE_FORMAT, getTag(), attribute.toString()); + } + + builder.withByteRange(ParseUtil.matchByteRange(matcher)); + } + }); + } + + @Override + public String getTag() { + return Constants.EXT_X_MAP; + } + + @Override + public boolean hasData() { + return true; + } + + @Override + public void parse(String line, ParseState state) throws ParseException { + lineParser.parse(line, state); + + final MapInfo.Builder builder = new MapInfo.Builder(); + + ParseUtil.parseAttributes(line, builder, state, HANDLERS, getTag()); + state.getMedia().mapInfo = builder.build(); + } + }; + + static final IExtTagParser EXT_X_BYTERANGE = new IExtTagParser() { + private final LineParser lineParser = new MediaPlaylistLineParser(this); + + @Override + public String getTag() { + return Constants.EXT_X_BYTERANGE_TAG; + } + + @Override + public boolean hasData() { + return true; + } + + @Override + public void parse(String line, ParseState state) throws ParseException { + lineParser.parse(line, state); + final Matcher matcher = ParseUtil.match(Constants.EXT_X_BYTERANGE_PATTERN, line, getTag()); + state.getMedia().byteRange = ParseUtil.matchByteRange(matcher); + } + }; +} diff --git a/src/main/java/com/iheartradio/m3u8/MediaPlaylistTagWriter.java b/src/main/java/com/iheartradio/m3u8/MediaPlaylistTagWriter.java new file mode 100644 index 0000000..10d7829 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/MediaPlaylistTagWriter.java @@ -0,0 +1,405 @@ +package com.iheartradio.m3u8; + +import com.iheartradio.m3u8.data.*; + +import java.io.IOException; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +abstract class MediaPlaylistTagWriter extends ExtTagWriter { + + @Override + public final void write(TagWriter tagWriter, Playlist playlist) throws IOException, ParseException { + if (playlist.hasMediaPlaylist()) { + doWrite(tagWriter, playlist, playlist.getMediaPlaylist()); + } + } + + public void doWrite(TagWriter tagWriter,Playlist playlist, MediaPlaylist mediaPlaylist) throws IOException, ParseException { + tagWriter.writeTag(getTag()); + } + + // media playlist tags + + static final IExtTagWriter EXT_X_ENDLIST = new MediaPlaylistTagWriter() { + @Override + public String getTag() { + return Constants.EXT_X_ENDLIST_TAG; + } + + @Override + boolean hasData() { + return false; + } + + @Override + public void doWrite(TagWriter tagWriter,Playlist playlist, MediaPlaylist mediaPlaylist) throws IOException { + if (!mediaPlaylist.isOngoing()) { + tagWriter.writeTag(getTag()); + } + } + }; + + static final IExtTagWriter EXT_X_I_FRAMES_ONLY = new MediaPlaylistTagWriter() { + @Override + public String getTag() { + return Constants.EXT_X_I_FRAMES_ONLY_TAG; + } + + @Override + boolean hasData() { + return false; + } + + @Override + public void doWrite(TagWriter tagWriter,Playlist playlist, MediaPlaylist mediaPlaylist) throws IOException { + if (mediaPlaylist.isIframesOnly()) { + tagWriter.writeTag(getTag()); + } + } + }; + + static final IExtTagWriter EXT_X_PLAYLIST_TYPE = new MediaPlaylistTagWriter() { + @Override + public String getTag() { + return Constants.EXT_X_PLAYLIST_TYPE_TAG; + } + + @Override + boolean hasData() { + return true; + } + + @Override + public void doWrite(TagWriter tagWriter, Playlist playlist, MediaPlaylist mediaPlaylist) throws IOException { + if (mediaPlaylist.getPlaylistType() != null) { + tagWriter.writeTag(getTag(), mediaPlaylist.getPlaylistType().getValue()); + } + } + }; + + static final IExtTagWriter EXT_X_START = new MediaPlaylistTagWriter() { + private final Map> HANDLERS = new HashMap>(); + + { + HANDLERS.put(Constants.TIME_OFFSET, new AttributeWriter() { + @Override + public boolean containsAttribute(StartData attributes) { + return true; + } + + @Override + public String write(StartData attributes) throws ParseException { + return Float.toString(attributes.getTimeOffset()); + } + }); + + HANDLERS.put(Constants.PRECISE, new AttributeWriter() { + @Override + public boolean containsAttribute(StartData attributes) { + return true; + } + + @Override + public String write(StartData attributes) throws ParseException { + if (attributes.isPrecise()) { + return Constants.YES; + } else { + return Constants.NO; + } + } + }); + } + + @Override + public String getTag() { + return Constants.EXT_X_START_TAG; + } + + @Override + boolean hasData() { + return true; + } + + @Override + public void doWrite(TagWriter tagWriter, Playlist playlist, MediaPlaylist mediaPlaylist) throws IOException, ParseException { + if (mediaPlaylist.hasStartData()) { + StartData startData = mediaPlaylist.getStartData(); + writeAttributes(tagWriter, startData, HANDLERS); + } + } + }; + + static final IExtTagWriter EXT_X_TARGETDURATION = new MediaPlaylistTagWriter() { + @Override + public String getTag() { + return Constants.EXT_X_TARGETDURATION_TAG; + } + + @Override + boolean hasData() { + return true; + } + + @Override + public void doWrite(TagWriter tagWriter, Playlist playlist, MediaPlaylist mediaPlaylist) throws IOException, ParseException { + tagWriter.writeTag(getTag(), Integer.toString(mediaPlaylist.getTargetDuration())); + } + }; + + static final IExtTagWriter EXT_X_MEDIA_SEQUENCE = new MediaPlaylistTagWriter() { + @Override + public String getTag() { + return Constants.EXT_X_MEDIA_SEQUENCE_TAG; + } + + @Override + boolean hasData() { + return true; + } + + @Override + public void doWrite(TagWriter tagWriter, Playlist playlist, MediaPlaylist mediaPlaylist) throws IOException ,ParseException { + tagWriter.writeTag(getTag(), Integer.toString(mediaPlaylist.getMediaSequenceNumber())); + }; + }; + + static final IExtTagWriter EXT_X_ALLOW_CACHE = new MediaPlaylistTagWriter() { + @Override + public String getTag() { + return Constants.EXT_X_ALLOW_CACHE_TAG; + } + + @Override + boolean hasData() { + return true; + } + + @Override + public void doWrite(TagWriter tagWriter, Playlist playlist, MediaPlaylist mediaPlaylist){ + + // deprecated + }; + }; + + // media segment tags + + static final SectionWriter MEDIA_SEGMENTS = new SectionWriter() { + @Override + public void write(TagWriter tagWriter, Playlist playlist) throws IOException, ParseException { + if (playlist.hasMediaPlaylist()) { + KeyWriter keyWriter = new KeyWriter(); + MapInfoWriter mapInfoWriter = new MapInfoWriter(); + + for (TrackData trackData : playlist.getMediaPlaylist().getTracks()) { + if (trackData.hasDiscontinuity()) { + tagWriter.writeTag(Constants.EXT_X_DISCONTINUITY_TAG); + } + + keyWriter.writeTrackData(tagWriter, playlist, trackData); + mapInfoWriter.writeTrackData(tagWriter, playlist, trackData); + + if (trackData.hasByteRange()) { + writeByteRange(tagWriter, trackData.getByteRange()); + } + + writeExtinf(tagWriter, playlist, trackData); + tagWriter.writeLine(trackData.getUri()); + } + } + } + }; + + private static void writeExtinf(TagWriter tagWriter, Playlist playlist, TrackData trackData) throws IOException { + final StringBuilder builder = new StringBuilder(); + + if (playlist.getCompatibilityVersion() < 3) { + builder.append(Integer.toString((int) trackData.getTrackInfo().duration)); + } else { + builder.append(Float.toString(trackData.getTrackInfo().duration)); + } + + builder.append(Constants.COMMA); + if (trackData.getTrackInfo().title != null) { + builder.append(trackData.getTrackInfo().title); + } + + tagWriter.writeTag(Constants.EXTINF_TAG, builder.toString()); + } + + private static void writeByteRange(TagWriter tagWriter, ByteRange byteRange) throws IOException { + String value; + + if (byteRange.getOffset() != null) { + value = String.valueOf(byteRange.getSubRangeLength()) + + '@' + String.valueOf(byteRange.getOffset()); + } else { + value = String.valueOf(byteRange.getSubRangeLength()); + } + + tagWriter.writeTag(Constants.EXT_X_BYTERANGE_TAG, value); + } + + static class KeyWriter extends MediaPlaylistTagWriter { + private final Map> HANDLERS = new HashMap>(); + + private EncryptionData mEncryptionData; + + { + HANDLERS.put(Constants.METHOD, new AttributeWriter() { + @Override + public boolean containsAttribute(EncryptionData attributes) { + return true; + } + + @Override + public String write(EncryptionData encryptionData) { + return encryptionData.getMethod().getValue(); + } + }); + + HANDLERS.put(Constants.URI, new AttributeWriter() { + @Override + public boolean containsAttribute(EncryptionData attributes) { + return true; + } + + @Override + public String write(EncryptionData encryptionData) throws ParseException { + return WriteUtil.writeQuotedString(encryptionData.getUri(), getTag()); + } + }); + + HANDLERS.put(Constants.IV, new AttributeWriter() { + @Override + public boolean containsAttribute(EncryptionData attribute) { + return attribute.hasInitializationVector(); + } + + @Override + public String write(EncryptionData encryptionData) { + return WriteUtil.writeHexadecimal(encryptionData.getInitializationVector()); + } + }); + + HANDLERS.put(Constants.KEY_FORMAT, new AttributeWriter() { + @Override + public boolean containsAttribute(EncryptionData attributes) { + return true; + } + + @Override + public String write(EncryptionData encryptionData) throws ParseException { + //TODO check for version 5 + return WriteUtil.writeQuotedString(encryptionData.getKeyFormat(), getTag(), true); + } + }); + + HANDLERS.put(Constants.KEY_FORMAT_VERSIONS, new AttributeWriter() { + @Override + public boolean containsAttribute(EncryptionData attributes) { + return true; + } + + @Override + public String write(EncryptionData encryptionData) throws ParseException { + //TODO check for version 5 + return WriteUtil.writeQuotedString(WriteUtil.join(encryptionData.getKeyFormatVersions(), Constants.LIST_SEPARATOR), getTag(), true); + } + }); + } + + @Override + public String getTag() { + return Constants.EXT_X_KEY_TAG; + } + + @Override + boolean hasData() { + return true; + } + + @Override + public void doWrite(TagWriter tagWriter, Playlist playlist, MediaPlaylist mediaPlaylist) throws IOException, ParseException { + writeAttributes(tagWriter, mEncryptionData, HANDLERS); + } + + void writeTrackData(TagWriter tagWriter, Playlist playlist, TrackData trackData) throws IOException, ParseException { + if (trackData != null && trackData.hasEncryptionData()) { + final EncryptionData encryptionData = trackData.getEncryptionData(); + + if (!encryptionData.equals(mEncryptionData)) { + mEncryptionData = encryptionData; + write(tagWriter, playlist); + } + } + } + } + + static class MapInfoWriter extends MediaPlaylistTagWriter { + private final Map> HANDLERS = new LinkedHashMap<>(); + + private MapInfo mMapInfo; + + { + HANDLERS.put(Constants.URI, new AttributeWriter() { + @Override + public String write(MapInfo attributes) throws ParseException { + return WriteUtil.writeQuotedString(attributes.getUri(), getTag()); + } + + @Override + public boolean containsAttribute(MapInfo attributes) { + return true; + } + }); + + HANDLERS.put(Constants.BYTERANGE, new AttributeWriter() { + @Override + public String write(MapInfo attributes) throws ParseException { + ByteRange byteRange = attributes.getByteRange(); + String value; + if (byteRange.hasOffset()) { + value = String.valueOf(byteRange.getSubRangeLength()) + + '@' + String.valueOf(byteRange.getOffset()); + } else { + value = String.valueOf(byteRange.getSubRangeLength()); + } + + return WriteUtil.writeQuotedString(value, getTag()); + } + + @Override + public boolean containsAttribute(MapInfo mapInfo) { + return mapInfo.hasByteRange(); + } + }); + } + + @Override + boolean hasData() { + return true; + } + + @Override + public String getTag() { + return Constants.EXT_X_MAP; + } + + @Override + public void doWrite(TagWriter tagWriter, Playlist playlist, MediaPlaylist mediaPlaylist) throws IOException, ParseException { + writeAttributes(tagWriter, mMapInfo, HANDLERS); + } + + void writeTrackData(TagWriter tagWriter, Playlist playlist, TrackData trackData) throws IOException, ParseException { + if (trackData != null && trackData.getMapInfo() != null) { + final MapInfo mapInfo = trackData.getMapInfo(); + if (!mapInfo.equals(mMapInfo)) { + mMapInfo = mapInfo; + write(tagWriter, playlist); + } + } + } + } +} diff --git a/src/main/java/com/iheartradio/m3u8/ParseException.java b/src/main/java/com/iheartradio/m3u8/ParseException.java new file mode 100644 index 0000000..48b5acd --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/ParseException.java @@ -0,0 +1,65 @@ +package com.iheartradio.m3u8; + +/** + * Represents a syntactic error in the input that prevented further parsing. + */ +public class ParseException extends Exception { + private static final long serialVersionUID = -2217152001086567983L; + + private final String mMessageSuffix; + + public final ParseExceptionType type; + + private String mInput; + + static ParseException create(ParseExceptionType type, String tag) { + return create(type, tag, null); + } + + static ParseException create(ParseExceptionType type, String tag, String context) { + final StringBuilder builder = new StringBuilder(); + + if (tag != null) { + builder.append(tag); + } + + if (context != null) { + if (builder.length() > 0) { + builder.append(" - "); + } + + builder.append(context); + } + + if (builder.length() > 0) { + return new ParseException(type, builder.toString()); + } else { + return new ParseException(type); + } + } + + ParseException(ParseExceptionType type) { + this(type, null); + } + + ParseException(ParseExceptionType type, String messageSuffix) { + this.type = type; + mMessageSuffix = messageSuffix; + } + + public String getInput() { + return mInput; + } + + void setInput(String input) { + mInput = input; + } + + public String getMessage() { + if (mMessageSuffix == null) { + return type.message; + } else { + return type.message + ": " + mMessageSuffix; + } + } +} diff --git a/src/main/java/com/iheartradio/m3u8/ParseExceptionType.java b/src/main/java/com/iheartradio/m3u8/ParseExceptionType.java new file mode 100644 index 0000000..d55ed75 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/ParseExceptionType.java @@ -0,0 +1,50 @@ +package com.iheartradio.m3u8; + +public enum ParseExceptionType { + AUTO_SELECT_DISABLED_FOR_DEFAULT("default media data must be auto selected"), + BAD_EXT_TAG_FORMAT("bad format found for an EXT tag"), + EMPTY_MEDIA_CHANNELS("CHANNELS is empty"), + EMPTY_MEDIA_CHARACTERISTICS("CHARACTERISTICS is empty"), + EMPTY_MEDIA_GROUP_ID("GROUP-ID is empty"), + EMPTY_MEDIA_NAME("NAME is empty"), + ILLEGAL_WHITESPACE("found illegal whitespace"), + INTERNAL_ERROR("there was an unrecoverable problem"), + INVALID_ATTRIBUTE_NAME("invalid attribute name"), + INVALID_COMPATIBILITY_VERSION("invalid compatibility version"), + INVALID_ENCRYPTION_METHOD("invalid encryption method"), + INVALID_HEXADECIMAL_STRING("a hexadecimal string was not properly formatted"), + INVALID_IV_SIZE("the initialization vector is the wrong size"), + INVALID_KEY_FORMAT_VERSIONS("invalid KEYFORMATVERSIONS"), + INVALID_MEDIA_IN_STREAM_ID("invalid media INSTREAM-ID"), + INVALID_MEDIA_TYPE("invalid media TYPE"), + INVALID_RESOLUTION_FORMAT("a resolution was not formatted properly"), + INVALID_QUOTED_STRING("a quoted string was not properly formatted"), + INVALID_DATE_TIME_FORMAT("a date-time string was not properly formatted"), + INVALID_BYTERANGE_FORMAT("a byte range string was not properly formatted"), + MASTER_IN_MEDIA("master playlist tags we found in a media playlist"), + MEDIA_IN_MASTER("media playlist tags we found in a master playlist"), + MISSING_ATTRIBUTE_NAME("missing the name of an attribute"), + MISSING_ATTRIBUTE_VALUE("missing the value of an attribute"), + MISSING_ATTRIBUTE_SEPARATOR("missing the separator in an attribute"), + MISSING_ENCRYPTION_URI("missing the URI for encrypted media segments"), + MISSING_EXT_TAG_SEPARATOR("missing the colon after an EXT tag"), + MISSING_TRACK_INFO("missing EXTINF for a track in an extended media playlist"), + MULTIPLE_ATTRIBUTE_NAME_INSTANCES("multiple instances of an attribute name found in an attribute list"), + MULTIPLE_EXT_TAG_INSTANCES("multiple instances of an EXT tag found for which only one is allowed"), + NOT_JAVA_INTEGER("only java integers are supported"), + NOT_JAVA_ENUM("only specific values are supported"), + NOT_JAVA_FLOAT("only java floats are supported"), + NOT_YES_OR_NO("the only valid values are YES and NO"), + UNCLOSED_QUOTED_STRING("a quoted string was not closed"), + UNKNOWN_PLAYLIST_TYPE("unable to determine playlist type"), + UNSUPPORTED_COMPATIBILITY_VERSION("open m3u8 does not support this version"), + UNSUPPORTED_EXT_TAG_DETECTED("unsupported ext tag detected"), + WHITESPACE_IN_TRACK("whitespace was found surrounding a track"), + REQUIRES_PROTOCOL_VERSION_4_OR_HIGHER("A Media Playlist REQUIRES protocol version 4 or higher"); + + final String message; + + private ParseExceptionType(String message) { + this.message = message; + } +} diff --git a/src/main/java/com/iheartradio/m3u8/ParseState.java b/src/main/java/com/iheartradio/m3u8/ParseState.java new file mode 100644 index 0000000..bf02469 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/ParseState.java @@ -0,0 +1,107 @@ +package com.iheartradio.m3u8; + +import com.iheartradio.m3u8.data.Playlist; +import com.iheartradio.m3u8.data.StartData; + +import java.util.ArrayList; +import java.util.List; + +class ParseState implements IParseState { + static final int NONE = -1; + + public final Encoding encoding; + public final List unknownTags = new ArrayList<>(); + + private MasterParseState mMasterParseState; + private MediaParseState mMediaParseState; + private boolean mIsExtended; + private int mCompatibilityVersion = NONE; + + public StartData startData; + + public ParseState(Encoding encoding) { + this.encoding = encoding; + } + + public boolean isMaster() { + return mMasterParseState != null; + } + + public MasterParseState getMaster() { + return mMasterParseState; + } + + public void setMaster() throws ParseException { + if (isMedia()) { + throw new ParseException(ParseExceptionType.MASTER_IN_MEDIA); + } + + if (mMasterParseState == null) { + mMasterParseState = new MasterParseState(); + } + } + + public boolean isMedia() { + return mMediaParseState != null; + } + + public MediaParseState getMedia() { + return mMediaParseState; + } + + public void setMedia() throws ParseException { + if (mMediaParseState == null) { + mMediaParseState = new MediaParseState(); + } + } + + public boolean isExtended() { + return mIsExtended; + } + + public void setExtended() { + mIsExtended = true; + } + + public void setIsIframesOnly() throws ParseException { + if (isMaster()) { + throw new ParseException(ParseExceptionType.MEDIA_IN_MASTER); + } + + getMedia().isIframesOnly = true; + } + + public int getCompatibilityVersion() { + return mCompatibilityVersion; + } + + public void setCompatibilityVersion(int compatibilityVersion) { + mCompatibilityVersion = compatibilityVersion; + } + + @Override + public Playlist buildPlaylist() throws ParseException { + final Playlist.Builder playlistBuilder = new Playlist.Builder(); + + if (isMaster()) { + playlistBuilder.withMasterPlaylist(buildInnerPlaylist(getMaster())); + } else if (isMedia()) { + playlistBuilder + .withMediaPlaylist(buildInnerPlaylist(getMedia())) + .withExtended(mIsExtended); + } else { + throw new ParseException(ParseExceptionType.UNKNOWN_PLAYLIST_TYPE); + } + + return playlistBuilder + .withCompatibilityVersion(mCompatibilityVersion == NONE ? Playlist.MIN_COMPATIBILITY_VERSION : mCompatibilityVersion) + .build(); + } + + private T buildInnerPlaylist(PlaylistParseState innerParseState) throws ParseException { + return innerParseState + .setUnknownTags(unknownTags) + .setStartData(startData) + .buildPlaylist(); + } +} diff --git a/src/main/java/com/iheartradio/m3u8/ParseUtil.java b/src/main/java/com/iheartradio/m3u8/ParseUtil.java new file mode 100644 index 0000000..3e23f0c --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/ParseUtil.java @@ -0,0 +1,265 @@ +package com.iheartradio.m3u8; + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.iheartradio.m3u8.data.ByteRange; +import com.iheartradio.m3u8.data.Resolution; + +final class ParseUtil { + + public static ParsingMode parsingMode; + + public static int parseInt(String string, String tag) throws ParseException { + try { + return Integer.parseInt(string); + } catch (NumberFormatException exception) { + throw ParseException.create(ParseExceptionType.NOT_JAVA_INTEGER, tag, string); + } + } + + public static > T parseEnum(String string, Class enumType, String tag) throws ParseException { + try { + return Enum.valueOf(enumType, string); + } catch (IllegalArgumentException exception) { + throw ParseException.create(ParseExceptionType.NOT_JAVA_ENUM, tag, string); + } + } + + public static String parseDateTime(String string, String tag) throws ParseException { + Matcher matcher = Constants.EXT_X_PROGRAM_DATE_TIME_PATTERN.matcher(string); + + if (!matcher.matches()) { + throw new ParseException(ParseExceptionType.INVALID_DATE_TIME_FORMAT, tag); + } + + return matcher.group(1); + } + + public static float parseFloat(String string, String tag) throws ParseException { + try { + return Float.parseFloat(string); + } catch (NumberFormatException exception) { + throw ParseException.create(ParseExceptionType.NOT_JAVA_FLOAT, tag, string); + } + } + + public static List parseHexadecimal(String hexString, String tag) throws ParseException { + final List bytes = new ArrayList(); + final Matcher matcher = Constants.HEXADECIMAL_PATTERN.matcher(hexString.toUpperCase(Locale.US)); + + if (matcher.matches()) { + String valueString = matcher.group(1); + if (valueString.length() % 2 != 0) { + throw ParseException.create(ParseExceptionType.INVALID_HEXADECIMAL_STRING, tag, hexString); + } + + for (int i = 0; i < valueString.length(); i += 2) { + bytes.add((byte)(Short.parseShort(valueString.substring(i, i+2), 16) & 0xFF)); + } + + return bytes; + } else { + throw ParseException.create(ParseExceptionType.INVALID_HEXADECIMAL_STRING, tag, hexString); + } + } + + private static byte hexCharToByte(char hex) { + if (hex >= 'A') { + return (byte) ((hex & 0xF) + 9); + } else { + return (byte) (hex & 0xF); + } + } + + public static boolean parseYesNo(Attribute attribute, String tag) throws ParseException { + if (attribute.value.equals(Constants.YES)) { + return true; + } else if (attribute.value.equals(Constants.NO)) { + return false; + } else { + throw ParseException.create(ParseExceptionType.NOT_YES_OR_NO, tag, attribute.toString()); + } + } + + public static Resolution parseResolution(String resolutionString, String tag) throws ParseException { + Matcher matcher = Constants.RESOLUTION_PATTERN.matcher(resolutionString); + + if (!matcher.matches()) { + throw new ParseException(ParseExceptionType.INVALID_RESOLUTION_FORMAT, tag); + } + + return new Resolution(parseInt(matcher.group(1), tag), parseInt(matcher.group(2), tag)); + } + + public static String parseQuotedString(String quotedString, String tag) throws ParseException { + final StringBuilder builder = new StringBuilder(); + + boolean isEscaping = false; + int quotesFound = 0; + + for (int i = 0; i < quotedString.length(); ++i) { + final char c = quotedString.charAt(i); + + if (i == 0 && c != '"') { + if (isWhitespace(c)) { + throw new ParseException(ParseExceptionType.ILLEGAL_WHITESPACE, tag); + } else { + throw new ParseException(ParseExceptionType.INVALID_QUOTED_STRING, tag); + } + } else if (quotesFound == 2) { + if (isWhitespace(c)) { + throw new ParseException(ParseExceptionType.ILLEGAL_WHITESPACE, tag); + } else { + throw new ParseException(ParseExceptionType.INVALID_QUOTED_STRING, tag); + } + } else if (i == quotedString.length() - 1) { + if (c != '"' || isEscaping) { + throw new ParseException(ParseExceptionType.UNCLOSED_QUOTED_STRING, tag); + } + } else { + if (isEscaping) { + builder.append(c); + isEscaping = false; + } else { + if (c == '\\') { + isEscaping = true; + } else if (c == '"') { + ++quotesFound; + } else { + builder.append(c); + } + } + } + } + + return builder.toString(); + } + + public static ByteRange matchByteRange(Matcher matcher) { + long subRangeLength = Long.parseLong(matcher.group(1)); + Long offset = matcher.group(2) != null ? Long.parseLong(matcher.group(2)) : null; + return new ByteRange(subRangeLength, offset); + } + + static boolean isWhitespace(char c) { + return c == ' ' || c == '\t' || c == '\r' || c == '\n'; + } + + public static String decodeUri(String encodedUri, Encoding encoding) throws ParseException { + try { + return URLDecoder.decode(encodedUri.replace("+", "%2B"), encoding.value); + } catch (UnsupportedEncodingException exception) { + throw new ParseException(ParseExceptionType.INTERNAL_ERROR); + } + } + + public static Matcher match(Pattern pattern, String line, String tag) throws ParseException { + final Matcher matcher = pattern.matcher(line); + + if (!matcher.matches()) { + throw ParseException.create(ParseExceptionType.BAD_EXT_TAG_FORMAT, tag, line); + } + + return matcher; + } + + public static void parseAttributes(String line, T builder, ParseState state, Map> handlers, String tag) throws ParseException { + for (Attribute attribute : parseAttributeList(line, tag)) { + if (handlers.containsKey(attribute.name)) { + handlers.get(attribute.name).parse(attribute, builder, state); + } else { + if(parsingMode != null && !parsingMode.allowUnkownAttributes) { + throw ParseException.create(ParseExceptionType.INVALID_ATTRIBUTE_NAME, tag, line); + } + } + } + } + + public static List parseAttributeList(String line, String tag) throws ParseException { + final List attributes = new ArrayList(); + final Set attributeNames = new HashSet(); + + for (String string : splitAttributeList(line, tag)) { + final int separator = string.indexOf(Constants.ATTRIBUTE_SEPARATOR); + final int quote = string.indexOf("\""); + + if (separator == -1 || (quote != -1 && quote < separator)) { + throw ParseException.create(ParseExceptionType.MISSING_ATTRIBUTE_SEPARATOR, tag, attributes.toString()); + } else { + //Even Apple playlists have sometimes spaces after a , + final String name = string.substring(0, separator).trim(); + final String value = string.substring(separator + 1); + + if (name.isEmpty()) { + throw ParseException.create(ParseExceptionType.MISSING_ATTRIBUTE_NAME, tag, attributes.toString()); + } + + if (value.isEmpty()) { + throw ParseException.create(ParseExceptionType.MISSING_ATTRIBUTE_VALUE, tag, attributes.toString()); + } + + if (!attributeNames.add(name)) { + throw ParseException.create(ParseExceptionType.MULTIPLE_ATTRIBUTE_NAME_INSTANCES, tag, attributes.toString()); + } + + attributes.add(new Attribute(name, value)); + } + } + + return attributes; + } + + public static List splitAttributeList(String line, String tag) throws ParseException { + final List splitIndices = new ArrayList(); + final List attributes = new ArrayList(); + + int startIndex = line.indexOf(Constants.EXT_TAG_END) + 1; + boolean isQuotedString = false; + boolean isEscaping = false; + + for (int i = startIndex; i < line.length(); i++) { + if (isQuotedString) { + if (isEscaping) { + isEscaping = false; + } else { + char c = line.charAt(i); + + if (c == '\\') { + isEscaping = true; + } else if (c == '"') { + isQuotedString = false; + } + } + } else { + char c = line.charAt(i); + + if (c == Constants.COMMA_CHAR) { + splitIndices.add(i); + } else if (c == '"') { + isQuotedString = true; + } + } + } + + if (isQuotedString) { + throw new ParseException(ParseExceptionType.UNCLOSED_QUOTED_STRING, tag); + } + + for (Integer splitIndex : splitIndices) { + attributes.add(line.substring(startIndex, splitIndex)); + startIndex = splitIndex + 1; + } + + attributes.add(line.substring(startIndex)); + return attributes; + } +} diff --git a/src/main/java/com/iheartradio/m3u8/ParsingMode.java b/src/main/java/com/iheartradio/m3u8/ParsingMode.java new file mode 100644 index 0000000..00b8f4a --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/ParsingMode.java @@ -0,0 +1,66 @@ +package com.iheartradio.m3u8; + +public class ParsingMode { + public static final ParsingMode STRICT = new Builder().build(); + + public static final ParsingMode LENIENT = new Builder() + .allowUnknownTags() + .allowNegativeNumbers() + .allowUnkownAttributes() + .build(); + + /** + * If true, unrecognized tags will not throw an exception. Instead, they will be made available in the playlist for custom parsing. + */ + public final boolean allowUnknownTags; + + /** + * If true, negative numbers in violation of the specification will not throw an exception. + */ + public final boolean allowNegativeNumbers; + + /** + * If true, negative numbers in violation of the specification will not throw an exception. + */ + public final boolean allowUnkownAttributes; + + private ParsingMode(final boolean allowUnknownTags, final boolean allowNegativeNumbers, final boolean allowUnkownAttributes) { + this.allowUnknownTags = allowUnknownTags; + this.allowNegativeNumbers = allowNegativeNumbers; + this.allowUnkownAttributes = allowUnkownAttributes; + } + + public static class Builder { + private boolean mAllowUnknownTags = false; + private boolean mAllowNegativeNumbers = false; + private boolean allowUnkownAttributes = false; + + /** + * Call to prevent throwing an exception when parsing unrecognized tags. + */ + public Builder allowUnknownTags() { + mAllowUnknownTags = true; + return this; + } + + /** + * Call to prevent throwing an exception when parsing negative numbers in violation of the specification. + */ + public Builder allowNegativeNumbers() { + mAllowNegativeNumbers = true; + return this; + } + + /** + * Call to prevent throwing an exception when parsing unkown attributes in EXT-X-STREAM-INF. + */ + public Builder allowUnkownAttributes() { + allowUnkownAttributes = true; + return this; + } + + public ParsingMode build() { + return new ParsingMode(mAllowUnknownTags, mAllowNegativeNumbers, allowUnkownAttributes); + } + } +} diff --git a/src/main/java/com/iheartradio/m3u8/PlaylistError.java b/src/main/java/com/iheartradio/m3u8/PlaylistError.java new file mode 100644 index 0000000..437c216 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/PlaylistError.java @@ -0,0 +1,132 @@ +package com.iheartradio.m3u8; + +public enum PlaylistError { + NO_PLAYLIST, + + /** + * Compatibility version cannot be less than Playlist.MIN_COMPATIBILITY_VERSION. + */ + COMPATIBILITY_TOO_LOW, + + /** + * A master or media playlist is required. + */ + NO_MASTER_OR_MEDIA, + + /** + * Having both a master and a media playlist is not allowed. + */ + BOTH_MASTER_AND_MEDIA, + + /** + * A master playlist must use the extend M3U tags. + */ + MASTER_NOT_EXTENDED, + + /** + * StartData requires a time offset. + */ + START_DATA_WITHOUT_TIME_OFFSET, + + /** + * EncryptionData requires a method. + */ + ENCRYPTION_DATA_WITHOUT_METHOD, + + /** + * MediaData requires a type. + */ + MEDIA_DATA_WITHOUT_TYPE, + + /** + * MediaData requires a group ID. + */ + MEDIA_DATA_WITHOUT_GROUP_ID, + + /** + * MediaData requires a name. + */ + MEDIA_DATA_WITHOUT_NAME, + + /** + * Close captions MediaData cannot have a URI. + */ + CLOSE_CAPTIONS_WITH_URI, + + /** + * Close captions MediaData requires in stream ID. + */ + CLOSE_CAPTIONS_WITHOUT_IN_STREAM_ID, + + /** + * MediaData can only have in stream ID if it is close captions. + */ + IN_STREAM_ID_WITHOUT_CLOSE_CAPTIONS, + + /** + * MediaData must be auto selected if it is default. + */ + DEFAULT_WITHOUT_AUTO_SELECT, + + /** + * Only subtitles MediaData can be forced. + */ + FORCED_WITHOUT_SUBTITLES, + + /** + * TrackData requires a location. + */ + TRACK_DATA_WITHOUT_URI, + + /** + * TrackData requires TrackInfo for playlists that use extended M3U tags. + */ + EXTENDED_TRACK_DATA_WITHOUT_TRACK_INFO, + + /** + * TrackInfo duration must be non-nagative. + * @see com.iheartradio.m3u8.ParsingMode#allowNegativeNumbers + */ + TRACK_INFO_WITH_NEGATIVE_DURATION, + + /** + * PlaylistData requires a URI. + */ + PLAYLIST_DATA_WITHOUT_URI, + + /** + * StreamInfo requires a bandwidth. + */ + STREAM_INFO_WITH_NO_BANDWIDTH, + + /** + * The average bandwidth in StreamInfo must be non-negative or StreamInfo.NO_BANDWIDTH. + */ + STREAM_INFO_WITH_INVALID_AVERAGE_BANDWIDTH, + + /** + * IFrameStreamInfo requires a URI. + */ + I_FRAME_STREAM_WITHOUT_URI, + + /** + * IFrameStreamInfo requires a bandwidth. + */ + I_FRAME_STREAM_WITH_NO_BANDWIDTH, + + /** + * The average bandwidth in IFrameStreamInfo must be non-negative or StreamInfo.NO_BANDWIDTH. + */ + I_FRAME_STREAM_WITH_INVALID_AVERAGE_BANDWIDTH, + + /** + * MapInfo requires a URI. + */ + MAP_INFO_WITHOUT_URI, + + /** + * If a byte range offset is not present, a previous media segment must appear in the playlist + * with a sub-range of the same media resource. + */ + BYTERANGE_WITH_UNDEFINED_OFFSET, +} diff --git a/src/main/java/com/iheartradio/m3u8/PlaylistException.java b/src/main/java/com/iheartradio/m3u8/PlaylistException.java new file mode 100644 index 0000000..6c8d6c2 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/PlaylistException.java @@ -0,0 +1,26 @@ +package com.iheartradio.m3u8; + +import java.util.Set; + +/** + * Represents a playlist with invalid data. + */ +public class PlaylistException extends Exception { + private static final long serialVersionUID = 7426782115004559238L; + + private final String mInput; + private final Set mErrors; + + public PlaylistException(String input, Set errors) { + mInput = input; + mErrors = errors; + } + + public String getInput() { + return mInput; + } + + public Set getErrors() { + return mErrors; + } +} diff --git a/src/main/java/com/iheartradio/m3u8/PlaylistLineParser.java b/src/main/java/com/iheartradio/m3u8/PlaylistLineParser.java new file mode 100644 index 0000000..e72588c --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/PlaylistLineParser.java @@ -0,0 +1,18 @@ +package com.iheartradio.m3u8; + +import com.iheartradio.m3u8.data.PlaylistData; + +class PlaylistLineParser implements LineParser { + @Override + public void parse(String line, ParseState state) { + final PlaylistData.Builder builder = new PlaylistData.Builder(); + final MasterParseState masterState = state.getMaster(); + + masterState.playlists.add(builder + .withUri(line) + .withStreamInfo(masterState.streamInfo) + .build()); + + masterState.streamInfo = null; + } +} diff --git a/src/main/java/com/iheartradio/m3u8/PlaylistParseState.java b/src/main/java/com/iheartradio/m3u8/PlaylistParseState.java new file mode 100644 index 0000000..bd71499 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/PlaylistParseState.java @@ -0,0 +1,11 @@ +package com.iheartradio.m3u8; + +import com.iheartradio.m3u8.data.StartData; + +import java.util.List; + +interface PlaylistParseState extends IParseState { + PlaylistParseState setUnknownTags(List unknownTags); + + PlaylistParseState setStartData(StartData startData); +} diff --git a/src/main/java/com/iheartradio/m3u8/PlaylistParser.java b/src/main/java/com/iheartradio/m3u8/PlaylistParser.java new file mode 100644 index 0000000..ea062c4 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/PlaylistParser.java @@ -0,0 +1,148 @@ +package com.iheartradio.m3u8; + +import java.io.IOException; +import java.io.InputStream; + +import com.iheartradio.m3u8.data.Playlist; + +public class PlaylistParser implements IPlaylistParser { + private final IPlaylistParser mPlaylistParser; + + /** + * Equivalent to: + *
+     *     new PlaylistParser(inputStream, format, filename, ParsingMode.STRICT);
+     * 
+ * @param inputStream an open input stream positioned at the beginning of the file + * @param format requires the playlist to be this format + * @param filename the extension of this filename will be used to determine the encoding required of the playlist + */ + public PlaylistParser(InputStream inputStream, Format format, String filename) { + this(inputStream, format, parseExtension(filename), ParsingMode.STRICT); + } + + /** + * @param inputStream an open input stream positioned at the beginning of the file + * @param format requires the playlist to be this format + * @param filename the extension of this filename will be used to determine the encoding required of the playlist + * @param parsingMode indicates how to handle unknown lines in the input stream + */ + public PlaylistParser(InputStream inputStream, Format format, String filename, ParsingMode parsingMode) { + this(inputStream, format, parseExtension(filename), parsingMode); + } + + /** + * Equivalent to: + *
+     *     new PlaylistParser(inputStream, format, extension, ParsingMode.STRICT);
+     * 
+ * @param inputStream an open input stream positioned at the beginning of the file + * @param format requires the playlist to be this format + * @param extension requires the playlist be encoded according to this extension {M3U : windows-1252, M3U8 : utf-8} + */ + public PlaylistParser(InputStream inputStream, Format format, Extension extension) { + this(inputStream, format, extension.encoding, ParsingMode.STRICT); + } + + /** + * @param inputStream an open input stream positioned at the beginning of the file + * @param format requires the playlist to be this format + * @param extension requires the playlist be encoded according to this extension {M3U : windows-1252, M3U8 : utf-8} + * @param parsingMode indicates how to handle unknown lines in the input stream + */ + public PlaylistParser(InputStream inputStream, Format format, Extension extension, ParsingMode parsingMode) { + this(inputStream, format, extension.encoding, parsingMode); + } + + /** + * Equivalent to: + *
+     *     new PlaylistParser(inputStream, format, encoding, ParsingMode.STRICT);
+     * 
+ * @param inputStream an open input stream positioned at the beginning of the file + * @param format requires the playlist to be this format + * @param encoding required encoding for the playlist + */ + public PlaylistParser(InputStream inputStream, Format format, Encoding encoding) { + this(inputStream, format, encoding, ParsingMode.STRICT); + } + + /** + * @param inputStream an open input stream positioned at the beginning of the file + * @param format requires the playlist to be this format + * @param encoding required encoding for the playlist + * @param parsingMode indicates how to handle unknown lines in the input stream + */ + public PlaylistParser(InputStream inputStream, Format format, Encoding encoding, ParsingMode parsingMode) { + if (inputStream == null) { + throw new IllegalArgumentException("inputStream is null"); + } + + if (format == null) { + throw new IllegalArgumentException("format is null"); + } + + if (encoding == null) { + throw new IllegalArgumentException("encoding is null"); + } + + if (parsingMode == null && format != Format.M3U) { + throw new IllegalArgumentException("parsingMode is null"); + } + + ParseUtil.parsingMode = parsingMode; + switch (format) { + case M3U: + mPlaylistParser = new M3uParser(inputStream, encoding); + break; + case EXT_M3U: + mPlaylistParser = new ExtendedM3uParser(inputStream, encoding, parsingMode); + break; + default: + throw new RuntimeException("unsupported format detected, this should be impossible: " + format); + } + } + + /** + * This will not close the InputStream. + * @return Playlist which is either a MasterPlaylist or a MediaPlaylist, this will never return null + * @throws IOException if the InputStream throws an IOException + * @throws java.io.EOFException if there is no data to parse + * @throws ParseException if there is a syntactic error in the playlist + * @throws PlaylistException if the data in the parsed playlist is invalid + */ + @Override + public Playlist parse() throws IOException, ParseException, PlaylistException { + return mPlaylistParser.parse(); + } + + /** + * @return true if there is more data to parse, false otherwise + */ + @Override + public boolean isAvailable() { + return mPlaylistParser.isAvailable(); + } + + private static Extension parseExtension(String filename) { + if (filename == null) { + throw new IllegalArgumentException("filename is null"); + } + + int index = filename.lastIndexOf("."); + + if (index == -1) { + throw new IllegalArgumentException("filename has no extension: " + filename); + } else { + final String extension = filename.substring(index + 1); + + if (Extension.M3U.value.equalsIgnoreCase(extension)) { + return Extension.M3U; + } else if (Extension.M3U8.value.equalsIgnoreCase(extension)) { + return Extension.M3U8; + } else { + throw new IllegalArgumentException("filename extension should be .m3u or .m3u8: " + filename); + } + } + } +} diff --git a/src/main/java/com/iheartradio/m3u8/PlaylistValidation.java b/src/main/java/com/iheartradio/m3u8/PlaylistValidation.java new file mode 100644 index 0000000..db894b5 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/PlaylistValidation.java @@ -0,0 +1,224 @@ +package com.iheartradio.m3u8; + +import com.iheartradio.m3u8.data.*; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class PlaylistValidation { + private final Set mErrors; + + private PlaylistValidation(Set errors) { + mErrors = Collections.unmodifiableSet(errors); + } + + @Override + public String toString() { + return new StringBuilder() + .append("(PlaylistValidation") + .append(" valid=").append(isValid()) + .append(" errors=").append(mErrors) + .append(")") + .toString(); + } + + public boolean isValid() { + return mErrors.isEmpty(); + } + + public Set getErrors() { + return mErrors; + } + + /** + * Equivalent to: PlaylistValidation.from(playlist, ParsingMode.STRICT) + */ + public static PlaylistValidation from(Playlist playlist) { + return PlaylistValidation.from(playlist, ParsingMode.STRICT); + } + + public static PlaylistValidation from(Playlist playlist, ParsingMode parsingMode) { + Set errors = new HashSet<>(); + + if (playlist == null) { + errors.add(PlaylistError.NO_PLAYLIST); + return new PlaylistValidation(errors); + } + + if (playlist.getCompatibilityVersion() < Playlist.MIN_COMPATIBILITY_VERSION) { + errors.add(PlaylistError.COMPATIBILITY_TOO_LOW); + } + + if (hasNoPlaylistTypes(playlist)) { + errors.add(PlaylistError.NO_MASTER_OR_MEDIA); + } else if (hasBothPlaylistTypes(playlist)) { + errors.add(PlaylistError.BOTH_MASTER_AND_MEDIA); + } + + if (playlist.hasMasterPlaylist()) { + if (!playlist.isExtended()) { + errors.add(PlaylistError.MASTER_NOT_EXTENDED); + } + + addMasterPlaylistErrors(playlist.getMasterPlaylist(), errors); + } + + if (playlist.hasMediaPlaylist()) { + addMediaPlaylistErrors(playlist.getMediaPlaylist(), errors, playlist.isExtended(), parsingMode); + } + + return new PlaylistValidation(errors); + } + + private static boolean hasNoPlaylistTypes(Playlist playlist) { + return !(playlist.hasMasterPlaylist() || playlist.hasMediaPlaylist()); + } + + private static boolean hasBothPlaylistTypes(Playlist playlist) { + return playlist.hasMasterPlaylist() && playlist.hasMediaPlaylist(); + } + + private static void addMasterPlaylistErrors(MasterPlaylist playlist, Set errors) { + for (PlaylistData playlistData : playlist.getPlaylists()) { + addPlaylistDataErrors(playlistData, errors); + } + + for (IFrameStreamInfo iFrameStreamInfo : playlist.getIFramePlaylists()) { + addIFrameStreamInfoErrors(iFrameStreamInfo, errors); + } + + for (MediaData mediaData : playlist.getMediaData()) { + addMediaDataErrors(mediaData, errors); + } + } + + private static void addMediaPlaylistErrors(MediaPlaylist playlist, Set errors, boolean isExtended, ParsingMode parsingMode) { + if (isExtended && playlist.hasStartData()) { + addStartErrors(playlist.getStartData(), errors); + } + + addByteRangeErrors(playlist.getTracks(), errors, parsingMode); + + for (TrackData trackData : playlist.getTracks()) { + addTrackDataErrors(trackData, errors, isExtended, parsingMode); + } + } + + private static void addByteRangeErrors(List tracks, Set errors, ParsingMode parsingMode) { + Set knownOffsets = new HashSet<>(); + for (TrackData track : tracks) { + if (!track.hasByteRange()) { + continue; + } + + if (track.getByteRange().hasOffset()) { + knownOffsets.add(track.getUri()); + } else if (!knownOffsets.contains(track.getUri())) { + errors.add(PlaylistError.BYTERANGE_WITH_UNDEFINED_OFFSET); + } + } + } + + private static void addStartErrors(StartData startData, Set errors) { + if (Float.isNaN(startData.getTimeOffset())) { + errors.add(PlaylistError.START_DATA_WITHOUT_TIME_OFFSET); + } + } + + private static void addPlaylistDataErrors(PlaylistData playlistData, Set errors) { + if (playlistData.getUri() == null || playlistData.getUri().isEmpty()) { + errors.add(PlaylistError.PLAYLIST_DATA_WITHOUT_URI); + } + + + if (playlistData.hasStreamInfo()) { + if (playlistData.getStreamInfo().getBandwidth() == StreamInfo.NO_BANDWIDTH) { + errors.add(PlaylistError.STREAM_INFO_WITH_NO_BANDWIDTH); + } + + if (playlistData.getStreamInfo().getAverageBandwidth() < StreamInfo.NO_BANDWIDTH) { + errors.add(PlaylistError.STREAM_INFO_WITH_INVALID_AVERAGE_BANDWIDTH); + } + } + } + + private static void addIFrameStreamInfoErrors(IFrameStreamInfo streamInfo, Set errors) { + if (streamInfo.getUri() == null || streamInfo.getUri().isEmpty()) { + errors.add(PlaylistError.I_FRAME_STREAM_WITHOUT_URI); + } + + if (streamInfo.getBandwidth() == StreamInfo.NO_BANDWIDTH) { + errors.add(PlaylistError.I_FRAME_STREAM_WITH_NO_BANDWIDTH); + } + + if (streamInfo.getAverageBandwidth() < StreamInfo.NO_BANDWIDTH) { + errors.add(PlaylistError.I_FRAME_STREAM_WITH_INVALID_AVERAGE_BANDWIDTH); + } + } + + private static void addMediaDataErrors(MediaData mediaData, Set errors) { + if (mediaData.getType() == null) { + errors.add(PlaylistError.MEDIA_DATA_WITHOUT_TYPE); + } + + if (mediaData.getGroupId() == null) { + errors.add(PlaylistError.MEDIA_DATA_WITHOUT_GROUP_ID); + } + + if (mediaData.getName() == null) { + errors.add(PlaylistError.MEDIA_DATA_WITHOUT_NAME); + } + + if (mediaData.getType() == MediaType.CLOSED_CAPTIONS) { + if (mediaData.hasUri()) { + errors.add(PlaylistError.CLOSE_CAPTIONS_WITH_URI); + } + + if (mediaData.getInStreamId() == null) { + errors.add(PlaylistError.CLOSE_CAPTIONS_WITHOUT_IN_STREAM_ID); + } + } else { + if (mediaData.getType() != MediaType.CLOSED_CAPTIONS && mediaData.getInStreamId() != null) { + errors.add(PlaylistError.IN_STREAM_ID_WITHOUT_CLOSE_CAPTIONS); + } + } + + if (mediaData.isDefault() && !mediaData.isAutoSelect()) { + errors.add(PlaylistError.DEFAULT_WITHOUT_AUTO_SELECT); + } + + if (mediaData.getType() != MediaType.SUBTITLES && mediaData.isForced()) { + errors.add(PlaylistError.FORCED_WITHOUT_SUBTITLES); + } + } + + private static void addTrackDataErrors(TrackData trackData, Set errors, boolean isExtended, ParsingMode parsingMode) { + if (trackData.getUri() == null || trackData.getUri().isEmpty()) { + errors.add(PlaylistError.TRACK_DATA_WITHOUT_URI); + } + + if (isExtended && !trackData.hasTrackInfo()) { + errors.add(PlaylistError.EXTENDED_TRACK_DATA_WITHOUT_TRACK_INFO); + } + + if (trackData.hasEncryptionData()) { + if (trackData.getEncryptionData().getMethod() == null) { + errors.add(PlaylistError.ENCRYPTION_DATA_WITHOUT_METHOD); + } + } + + if (trackData.hasTrackInfo()) { + if (!parsingMode.allowNegativeNumbers && trackData.getTrackInfo().duration < 0) { + errors.add(PlaylistError.TRACK_INFO_WITH_NEGATIVE_DURATION); + } + } + + if (trackData.hasMapInfo()) { + if (trackData.getMapInfo().getUri() == null || trackData.getMapInfo().getUri().isEmpty()) { + errors.add(PlaylistError.MAP_INFO_WITHOUT_URI); + } + } + } +} diff --git a/src/main/java/com/iheartradio/m3u8/PlaylistWriter.java b/src/main/java/com/iheartradio/m3u8/PlaylistWriter.java new file mode 100644 index 0000000..f84d11b --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/PlaylistWriter.java @@ -0,0 +1,131 @@ +package com.iheartradio.m3u8; + +import java.io.IOException; +import java.io.OutputStream; + +import com.iheartradio.m3u8.data.Playlist; + +import static com.iheartradio.m3u8.Constants.UTF_8_BOM_BYTES; + +public class PlaylistWriter { + private final Writer mWriter; + private final OutputStream mOutputStream; + private final boolean mShouldWriteByteOrderMark; + + private boolean mFirstWrite = true; + + /** + * This writes the given Playlist to the given OutputStream with a prepended BOM (Byte Order Mark). + * This exists for convenience if you absolutely need a BOM. + * From the specification: "They (playlist files) MUST NOT contain any byte order mark (BOM); Clients SHOULD reject Playlists which contain a BOM..." + * + * @param outputStream OutputStream playlists will be written to + * @param format format in which to write the playlist + * @param encoding encoding in which to write the playlist + */ + public PlaylistWriter(OutputStream outputStream, Format format, Encoding encoding) { + this(outputStream, format, encoding, false); + } + + private PlaylistWriter(OutputStream outputStream, Format format, Encoding encoding, boolean useByteOrderMark) { + if (outputStream == null) { + throw new IllegalArgumentException("outputStream is null"); + } + + if (format == null) { + throw new IllegalArgumentException("format is null"); + } + + if (encoding == null) { + throw new IllegalArgumentException("encoding is null"); + } + + mOutputStream = outputStream; + mShouldWriteByteOrderMark = encoding.supportsByteOrderMark && useByteOrderMark; + + switch (format) { + case M3U: + mWriter = new M3uWriter(outputStream, encoding); + break; + case EXT_M3U: + mWriter = new ExtendedM3uWriter(outputStream, encoding); + break; + default: + throw new RuntimeException("unsupported format detected, this should be impossible: " + format); + } + } + + /** + * Writes the given Playlist to the contained OutputStream. + * + * @throws IOException + * @throws ParseException if the data is improperly formatted + * @throws PlaylistException if the representation of the playlist is invalid, + * that is, if PlaylistValidation.from(playlist).isValid() == false + */ + public void write(Playlist playlist) throws IOException, ParseException, PlaylistException { + final PlaylistValidation validation = PlaylistValidation.from(playlist); + + if (!validation.isValid()) { + throw new PlaylistException("", validation.getErrors()); + } + + writeByteOrderMark(); + mWriter.write(playlist); + mFirstWrite = false; + } + + private void writeByteOrderMark() throws IOException { + if (mShouldWriteByteOrderMark && mFirstWrite) { + for (int i = 0; i < UTF_8_BOM_BYTES.length; ++i) { + mOutputStream.write(UTF_8_BOM_BYTES[i]); + } + } + } + + public static class Builder { + private OutputStream mOutputStream; + private Format mFormat; + private Encoding mEncoding; + private boolean mUseByteOrderMark; + + /** + * @param outputStream OutputStream playlists will be written to + */ + public Builder withOutputStream(OutputStream outputStream) { + mOutputStream = outputStream; + return this; + } + + /** + * @param format format in which to write the playlist + */ + public Builder withFormat(Format format) { + mFormat = format; + return this; + } + + /** + * @param encoding encoding in which to write the playlist + */ + public Builder withEncoding(Encoding encoding) { + mEncoding = encoding; + return this; + } + + /** + * When using a BOM, the first call to write will prepend the BOM character to the playlist. Subsequent + * calls to write will not prepend the BOM to support writing multiple ENDLIST delimited playlists. + * + * @deprecated The specification explicitly says that playlists MUST NOT contain a byte order mark (BOM) + */ + public Builder useByteOrderMark() { + mUseByteOrderMark = true; + return this; + } + + public PlaylistWriter build() { + return new PlaylistWriter(mOutputStream, mFormat, mEncoding, mUseByteOrderMark); + } + } +} diff --git a/src/main/java/com/iheartradio/m3u8/SectionWriter.java b/src/main/java/com/iheartradio/m3u8/SectionWriter.java new file mode 100644 index 0000000..842a6b3 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/SectionWriter.java @@ -0,0 +1,9 @@ +package com.iheartradio.m3u8; + +import com.iheartradio.m3u8.data.Playlist; + +import java.io.IOException; + +interface SectionWriter { + void write(TagWriter tagWriter, Playlist playlist) throws IOException, ParseException; +} diff --git a/src/main/java/com/iheartradio/m3u8/TagWriter.java b/src/main/java/com/iheartradio/m3u8/TagWriter.java new file mode 100644 index 0000000..788b6a2 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/TagWriter.java @@ -0,0 +1,33 @@ +package com.iheartradio.m3u8; + +import java.io.IOException; +import java.io.OutputStreamWriter; + +class TagWriter { + private final OutputStreamWriter mWriter; + + public TagWriter(OutputStreamWriter outputStreamWriter) { + mWriter = outputStreamWriter; + } + + public void write(String str) throws IOException { + mWriter.write(str); + } + + public void writeLine(String line) throws IOException { + write(line + Constants.WRITE_NEW_LINE); + } + + public void writeTag(String tag) throws IOException { + writeLine(Constants.COMMENT_PREFIX + tag); + } + + public void writeTag(String tag, String value) throws IOException { + writeLine(Constants.COMMENT_PREFIX + tag + Constants.EXT_TAG_END + value); + } + + public void flush() throws IOException { + mWriter.flush(); + } + +} diff --git a/src/main/java/com/iheartradio/m3u8/TrackLineParser.java b/src/main/java/com/iheartradio/m3u8/TrackLineParser.java new file mode 100644 index 0000000..63eaef1 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/TrackLineParser.java @@ -0,0 +1,31 @@ +package com.iheartradio.m3u8; + +import com.iheartradio.m3u8.data.TrackData; + +class TrackLineParser implements LineParser { + @Override + public void parse(String line, ParseState state) throws ParseException { + final TrackData.Builder builder = new TrackData.Builder(); + final MediaParseState mediaState = state.getMedia(); + + if (state.isExtended() && mediaState.trackInfo == null) { + throw ParseException.create(ParseExceptionType.MISSING_TRACK_INFO, line); + } + + mediaState.tracks.add(builder + .withUri(line) + .withTrackInfo(mediaState.trackInfo) + .withEncryptionData(mediaState.encryptionData) + .withProgramDateTime(mediaState.programDateTime) + .withDiscontinuity(mediaState.hasDiscontinuity) + .withMapInfo(mediaState.mapInfo) + .withByteRange(mediaState.byteRange) + .build()); + + mediaState.trackInfo = null; + mediaState.programDateTime = null; + mediaState.hasDiscontinuity = false; + mediaState.mapInfo = null; + mediaState.byteRange = null; + } +} diff --git a/src/main/java/com/iheartradio/m3u8/WriteUtil.java b/src/main/java/com/iheartradio/m3u8/WriteUtil.java new file mode 100644 index 0000000..99c295e --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/WriteUtil.java @@ -0,0 +1,89 @@ +package com.iheartradio.m3u8; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.List; + +import com.iheartradio.m3u8.data.Resolution; + +public class WriteUtil { + + public static String writeYesNo(boolean yes) { + if (yes) { + return Constants.YES; + } else { + return Constants.NO; + } + } + + public static String writeHexadecimal(List hex) { + if (hex == null || hex.size() == 0) { + throw new IllegalArgumentException("hex might not be null or empty!"); + } + + final String prefix = "0x"; + StringBuilder builder = new StringBuilder(hex.size() + prefix.length()); + builder.append(prefix); + for(Byte b : hex) { + builder.append(String.format("%02x", b)); + } + return builder.toString(); + } + + public static String writeResolution(Resolution r) { + return r.width + "x" + r.height; + } + + public static String writeQuotedString(String unquotedString, String tag) throws ParseException { + return writeQuotedString(unquotedString, tag, false); + } + public static String writeQuotedString(String unquotedString, String tag, boolean optional) throws ParseException { + if (unquotedString != null || !optional) { + final StringBuilder builder = new StringBuilder(unquotedString.length() + 2); + builder.append("\""); + + for (int i = 0; i < unquotedString.length(); ++i) { + final char c = unquotedString.charAt(i); + + if (i == 0 && ParseUtil.isWhitespace(c)) { + throw new ParseException(ParseExceptionType.ILLEGAL_WHITESPACE, tag); + } else if (c == '"') { + builder.append('\\').append(c); + } else { + builder.append(c); + } + } + + builder.append("\""); + return builder.toString(); + } + + return "\"\""; + } + + public static String encodeUri(String decodedUri) throws ParseException { + try { + return URLEncoder.encode(decodedUri.replace("%2B", "+"), "utf-8"); + } catch (UnsupportedEncodingException exception) { + throw new ParseException(ParseExceptionType.INTERNAL_ERROR); + } + } + + public static String join(List valueList, String separator) { + if (valueList == null || valueList.size() == 0) { + throw new IllegalArgumentException("valueList might not be null or empty!"); + } + if (separator == null) { + throw new IllegalArgumentException("separator might not be null!"); + } + StringBuilder sb = new StringBuilder(); + for(int i = 0; i < valueList.size(); i++) { + sb.append(valueList.get(i).toString()); + if (i + 1 < valueList.size()) { + sb.append(separator); + } + } + return sb.toString(); + } + +} diff --git a/src/main/java/com/iheartradio/m3u8/Writer.java b/src/main/java/com/iheartradio/m3u8/Writer.java new file mode 100644 index 0000000..79ca9fd --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/Writer.java @@ -0,0 +1,42 @@ +package com.iheartradio.m3u8; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.UnsupportedEncodingException; + +import com.iheartradio.m3u8.data.Playlist; + +abstract class Writer { + final TagWriter tagWriter; + + Writer(OutputStream outputStream, Encoding encoding) { + try { + tagWriter = new TagWriter(new OutputStreamWriter(outputStream, encoding.getValue())); + } catch (UnsupportedEncodingException e) { + throw new IllegalArgumentException(e.getMessage(), e); + } + } + + void writeTagLine(String tag) throws IOException { + writeLine(Constants.COMMENT_PREFIX + tag); + } + + void writeTagLine(String tag, Object value) throws IOException { + writeLine(Constants.COMMENT_PREFIX + tag + Constants.EXT_TAG_END + value); + } + + void writeLine(String line) throws IOException { + tagWriter.write(line); + tagWriter.write("\n"); + } + + final void write(Playlist playlist) throws IOException, ParseException, PlaylistException { + doWrite(playlist); + + tagWriter.flush(); + } + + abstract void doWrite(Playlist playlist) throws IOException, ParseException, PlaylistException; + +} diff --git a/src/main/java/com/iheartradio/m3u8/data/ByteRange.java b/src/main/java/com/iheartradio/m3u8/data/ByteRange.java new file mode 100644 index 0000000..1344bae --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/data/ByteRange.java @@ -0,0 +1,56 @@ +package com.iheartradio.m3u8.data; + +import java.util.Objects; + +public class ByteRange { + private final long mSubRangeLength; + private final Long mOffset; + + public ByteRange(long subRangeLength, long offset) { + this.mSubRangeLength = subRangeLength; + this.mOffset = offset; + } + + public ByteRange(long subRangeLength, Long offset) { + this.mSubRangeLength = subRangeLength; + this.mOffset = offset; + } + + public ByteRange(long subRangeLength) { + this(subRangeLength, null); + } + + public long getSubRangeLength() { + return mSubRangeLength; + } + + public Long getOffset() { + return mOffset; + } + + public boolean hasOffset() { + return mOffset != null; + } + + @Override + public String toString() { + return "ByteRange{" + + "mSubRangeLength=" + mSubRangeLength + + ", mOffset=" + mOffset + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ByteRange byteRange = (ByteRange) o; + return mSubRangeLength == byteRange.mSubRangeLength && + Objects.equals(mOffset, byteRange.mOffset); + } + + @Override + public int hashCode() { + return Objects.hash(mSubRangeLength, mOffset); + } +} diff --git a/src/main/java/com/iheartradio/m3u8/data/DataUtil.java b/src/main/java/com/iheartradio/m3u8/data/DataUtil.java new file mode 100644 index 0000000..6a47c7b --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/data/DataUtil.java @@ -0,0 +1,10 @@ +package com.iheartradio.m3u8.data; + +import java.util.Collections; +import java.util.List; + +class DataUtil { + static List emptyOrUnmodifiable(final List list) { + return list == null ? Collections.emptyList() : Collections.unmodifiableList(list); + } +} diff --git a/src/main/java/com/iheartradio/m3u8/data/EncryptionData.java b/src/main/java/com/iheartradio/m3u8/data/EncryptionData.java new file mode 100644 index 0000000..6120763 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/data/EncryptionData.java @@ -0,0 +1,129 @@ +package com.iheartradio.m3u8.data; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +public class EncryptionData { + private final EncryptionMethod mMethod; + private final String mUri; + private final List mInitializationVector; + private final String mKeyFormat; + private final List mKeyFormatVersions; + + private EncryptionData(EncryptionMethod method, String uri, List initializationVector, String keyFormat, List keyFormats) { + mMethod = method; + mUri = uri; + mInitializationVector = initializationVector == null ? null : Collections.unmodifiableList(initializationVector); + mKeyFormat = keyFormat; + mKeyFormatVersions = keyFormats == null ? null : Collections.unmodifiableList(keyFormats); + } + + public EncryptionMethod getMethod() { + return mMethod; + } + + public boolean hasUri() { + return mUri != null && !mUri.isEmpty(); + } + + public String getUri() { + return mUri; + } + + public boolean hasInitializationVector() { + return mInitializationVector != null; + } + + public List getInitializationVector() { + return mInitializationVector; + } + + public boolean hasKeyFormat() { + return mKeyFormat != null; + } + + public String getKeyFormat() { + return mKeyFormat; + } + + public boolean hasKeyFormatVersions() { + return mKeyFormatVersions != null; + } + + public List getKeyFormatVersions() { + return mKeyFormatVersions; + } + + public Builder buildUpon() { + return new Builder(mMethod, mUri, mInitializationVector, mKeyFormat, mKeyFormatVersions); + } + + @Override + public int hashCode() { + return Objects.hash(mInitializationVector, mKeyFormat, mKeyFormatVersions, mMethod, mUri); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof EncryptionData)) { + return false; + } + + EncryptionData other = (EncryptionData) o; + + return Objects.equals(this.mInitializationVector, other.mInitializationVector) && + Objects.equals(this.mKeyFormat, other.mKeyFormat) && + Objects.equals(this.mKeyFormatVersions, other.mKeyFormatVersions) && + Objects.equals(this.mMethod, other.mMethod) && + Objects.equals(this.mUri, other.mUri); + } + + public static class Builder { + private EncryptionMethod mMethod; + private String mUri; + private List mInitializationVector; + private String mKeyFormat; + private List mKeyFormatVersions; + + public Builder() { + } + + private Builder(EncryptionMethod method, String uri, List initializationVector, String keyFormat, List keyFormatVersions) { + mMethod = method; + mUri = uri; + mInitializationVector = initializationVector; + mKeyFormat = keyFormat; + mKeyFormatVersions = keyFormatVersions; + } + + public Builder withMethod(EncryptionMethod method) { + mMethod = method; + return this; + } + + public Builder withUri(String uri) { + mUri = uri; + return this; + } + + public Builder withInitializationVector(List initializationVector) { + mInitializationVector = initializationVector; + return this; + } + + public Builder withKeyFormat(String keyFormat) { + mKeyFormat = keyFormat; + return this; + } + + public Builder withKeyFormatVersions(List keyFormatVersions) { + mKeyFormatVersions = keyFormatVersions; + return this; + } + + public EncryptionData build() { + return new EncryptionData(mMethod, mUri, mInitializationVector, mKeyFormat, mKeyFormatVersions); + } + } +} diff --git a/src/main/java/com/iheartradio/m3u8/data/EncryptionMethod.java b/src/main/java/com/iheartradio/m3u8/data/EncryptionMethod.java new file mode 100644 index 0000000..89ccc88 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/data/EncryptionMethod.java @@ -0,0 +1,32 @@ +package com.iheartradio.m3u8.data; + +import java.util.HashMap; +import java.util.Map; + +public enum EncryptionMethod { + NONE("NONE"), + AES("AES-128"), + SAMPLE_AES("SAMPLE-AES"); + + private static final Map sMap = new HashMap(); + + private final String value; + + static { + for (EncryptionMethod encryptionMethod : EncryptionMethod.values()) { + sMap.put(encryptionMethod.value, encryptionMethod); + } + } + + private EncryptionMethod(String value) { + this.value = value; + } + + public static EncryptionMethod fromValue(String value) { + return sMap.get(value); + } + + public String getValue() { + return this.value; + } +} diff --git a/src/main/java/com/iheartradio/m3u8/data/IFrameStreamInfo.java b/src/main/java/com/iheartradio/m3u8/data/IFrameStreamInfo.java new file mode 100644 index 0000000..2b86661 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/data/IFrameStreamInfo.java @@ -0,0 +1,214 @@ +package com.iheartradio.m3u8.data; + +import java.util.List; +import java.util.Objects; + +public class IFrameStreamInfo implements IStreamInfo { + public static final int NO_BANDWIDTH = -1; + + private final int mBandwidth; + private final int mAverageBandwidth; + private final List mCodecs; + private final Resolution mResolution; + private final float mFrameRate; + private final String mVideo; + private final String mUri; + + private IFrameStreamInfo( + int bandwidth, + int averageBandwidth, + List codecs, + Resolution resolution, + float frameRate, + String video, + String uri) { + mBandwidth = bandwidth; + mAverageBandwidth = averageBandwidth; + mCodecs = codecs; + mResolution = resolution; + mFrameRate = frameRate; + mVideo = video; + mUri = uri; + } + + @Override + public int getBandwidth() { + return mBandwidth; + } + + @Override + public boolean hasAverageBandwidth() { + return mAverageBandwidth != NO_BANDWIDTH; + } + + @Override + public int getAverageBandwidth() { + return mAverageBandwidth; + } + + @Override + public boolean hasCodecs() { + return mCodecs != null; + } + + @Override + public List getCodecs() { + return mCodecs; + } + + @Override + public boolean hasResolution() { + return mResolution != null; + } + + @Override + public Resolution getResolution() { + return mResolution; + } + + @Override + public boolean hasFrameRate() { + return !Float.isNaN(mFrameRate); + } + + @Override + public float getFrameRate() { + return mFrameRate; + } + + @Override + public boolean hasVideo() { + return mVideo != null; + } + + @Override + public String getVideo() { + return mVideo; + } + + public String getUri() { + return mUri; + } + + public Builder buildUpon() { + return new Builder( + mBandwidth, + mAverageBandwidth, + mCodecs, + mResolution, + mFrameRate, + mVideo, + mUri); + } + + @Override + public int hashCode() { + return Objects.hash( + mBandwidth, + mAverageBandwidth, + mCodecs, + mResolution, + mFrameRate, + mVideo, + mUri); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof IFrameStreamInfo)) { + return false; + } + + IFrameStreamInfo other = (IFrameStreamInfo) o; + + return mBandwidth == other.mBandwidth && + mAverageBandwidth == other.mAverageBandwidth && + Objects.equals(mCodecs, other.mCodecs) && + Objects.equals(mResolution, other.mResolution) && + Objects.equals(mFrameRate, other.mFrameRate) && + Objects.equals(mVideo, other.mVideo) && + Objects.equals(mUri, other.mUri); + } + + public static class Builder implements StreamInfoBuilder { + private int mBandwidth = NO_BANDWIDTH; + private int mAverageBandwidth = NO_BANDWIDTH; + private List mCodecs; + private Resolution mResolution; + private float mFrameRate = Float.NaN; + private String mVideo; + private String mUri; + + public Builder() { + } + + private Builder( + int bandwidth, + int averageBandwidth, + List codecs, + Resolution resolution, + float frameRate, + String video, + String uri) { + mBandwidth = bandwidth; + mAverageBandwidth = averageBandwidth; + mCodecs = codecs; + mResolution = resolution; + mFrameRate = frameRate; + mVideo = video; + mUri = uri; + } + + @Override + public Builder withBandwidth(int bandwidth) { + mBandwidth = bandwidth; + return this; + } + + @Override + public Builder withAverageBandwidth(int averageBandwidth) { + mAverageBandwidth = averageBandwidth; + return this; + } + + @Override + public Builder withCodecs(List codecs) { + mCodecs = codecs; + return this; + } + + @Override + public Builder withResolution(Resolution resolution) { + mResolution = resolution; + return this; + } + + @Override + public Builder withFrameRate(float frameRate) { + mFrameRate = frameRate; + return this; + } + + @Override + public Builder withVideo(String video) { + mVideo = video; + return this; + } + + public Builder withUri(String uri) { + mUri = uri; + return this; + } + + public IFrameStreamInfo build() { + return new IFrameStreamInfo( + mBandwidth, + mAverageBandwidth, + mCodecs, + mResolution, + mFrameRate, + mVideo, + mUri); + } + } +} diff --git a/src/main/java/com/iheartradio/m3u8/data/IStreamInfo.java b/src/main/java/com/iheartradio/m3u8/data/IStreamInfo.java new file mode 100644 index 0000000..7b313ae --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/data/IStreamInfo.java @@ -0,0 +1,27 @@ +package com.iheartradio.m3u8.data; + +import java.util.List; + +public interface IStreamInfo { + public int getBandwidth(); + + boolean hasAverageBandwidth(); + + int getAverageBandwidth(); + + boolean hasCodecs(); + + List getCodecs(); + + boolean hasResolution(); + + Resolution getResolution(); + + boolean hasFrameRate(); + + float getFrameRate(); + + boolean hasVideo(); + + String getVideo(); +} diff --git a/src/main/java/com/iheartradio/m3u8/data/MapInfo.java b/src/main/java/com/iheartradio/m3u8/data/MapInfo.java new file mode 100644 index 0000000..b5d58f8 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/data/MapInfo.java @@ -0,0 +1,78 @@ +package com.iheartradio.m3u8.data; + +import java.util.Objects; + +public class MapInfo { + private final String uri; + private final ByteRange byteRange; + + public MapInfo(String uri, ByteRange byteRange) { + this.uri = uri; + this.byteRange = byteRange; + } + + public String getUri() { + return uri; + } + + public boolean hasByteRange() { + return byteRange != null; + } + + public ByteRange getByteRange() { + return byteRange; + } + + public Builder buildUpon() { + return new Builder(uri, byteRange); + } + + @Override + public String toString() { + return "MapInfo{" + + "uri='" + uri + '\'' + + ", byteRange='" + byteRange + '\'' + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + MapInfo mapInfo = (MapInfo) o; + return Objects.equals(uri, mapInfo.uri) && + Objects.equals(byteRange, mapInfo.byteRange); + } + + @Override + public int hashCode() { + return Objects.hash(uri, byteRange); + } + + public static class Builder { + private String mUri; + private ByteRange mByteRange; + + public Builder() { + } + + private Builder(String uri, ByteRange byteRange) { + this.mUri = uri; + this.mByteRange = byteRange; + } + + public Builder withUri(String uri) { + this.mUri = uri; + return this; + } + + public Builder withByteRange(ByteRange byteRange) { + this.mByteRange = byteRange; + return this; + } + + public MapInfo build() { + return new MapInfo(mUri, mByteRange); + } + } +} diff --git a/src/main/java/com/iheartradio/m3u8/data/MasterPlaylist.java b/src/main/java/com/iheartradio/m3u8/data/MasterPlaylist.java new file mode 100644 index 0000000..2cbc47e --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/data/MasterPlaylist.java @@ -0,0 +1,137 @@ +package com.iheartradio.m3u8.data; + +import java.util.List; +import java.util.Objects; + +public class MasterPlaylist { + private final List mPlaylists; + private final List mIFramePlaylists; + private final List mMediaData; + private final List mUnknownTags; + private final StartData mStartData; + + private MasterPlaylist(List playlists, List iFramePlaylists, List mediaData, List unknownTags, StartData startData) { + mPlaylists = DataUtil.emptyOrUnmodifiable(playlists); + mIFramePlaylists = DataUtil.emptyOrUnmodifiable(iFramePlaylists); + mMediaData = DataUtil.emptyOrUnmodifiable(mediaData); + mUnknownTags = DataUtil.emptyOrUnmodifiable(unknownTags); + mStartData = startData; + } + + public List getPlaylists() { + return mPlaylists; + } + + public List getIFramePlaylists() { + return mIFramePlaylists; + } + + public List getMediaData() { + return mMediaData; + } + + public boolean hasUnknownTags() { + return mUnknownTags.size() > 0; + } + + public List getUnknownTags() { + return mUnknownTags; + } + + public boolean hasStartData() { + return mStartData != null; + } + + public StartData getStartData() { + return mStartData; + } + + public Builder buildUpon() { + return new Builder(mPlaylists, mIFramePlaylists, mMediaData, mUnknownTags); + } + + @Override + public int hashCode() { + return Objects.hash(mMediaData, mPlaylists, mIFramePlaylists, mUnknownTags, mStartData); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof MasterPlaylist)) { + return false; + } + + MasterPlaylist other = (MasterPlaylist) o; + + return Objects.equals(mMediaData, other.mMediaData) && + Objects.equals(mPlaylists, other.mPlaylists) && + Objects.equals(mIFramePlaylists, other.mIFramePlaylists) && + Objects.equals(mUnknownTags, other.mUnknownTags) && + Objects.equals(mStartData, other.mStartData); + } + + @Override + public String toString() { + return new StringBuilder() + .append("(MasterPlaylist") + .append(" mPlaylists=").append(mPlaylists.toString()) + .append(" mIFramePlaylists=").append(mIFramePlaylists.toString()) + .append(" mMediaData=").append(mMediaData.toString()) + .append(" mUnknownTags=").append(mUnknownTags.toString()) + .append(" mStartData=").append(mStartData.toString()) + .append(")") + .toString(); + } + + public static class Builder { + private List mPlaylists; + private List mIFramePlaylists; + private List mMediaData; + private List mUnknownTags; + private StartData mStartData; + + public Builder() { + } + + private Builder(List playlists, List iFramePlaylists, List mediaData, List unknownTags) { + mPlaylists = playlists; + mIFramePlaylists = iFramePlaylists; + mMediaData = mediaData; + mUnknownTags = unknownTags; + } + + private Builder(List playlists, List mediaData) { + mPlaylists = playlists; + mMediaData = mediaData; + } + + public Builder withPlaylists(List playlists) { + mPlaylists = playlists; + return this; + } + + public Builder withIFramePlaylists(List iFramePlaylists) { + mIFramePlaylists = iFramePlaylists; + return this; + } + + public Builder withMediaData(List mediaData) { + mMediaData = mediaData; + return this; + } + + public Builder withUnknownTags(List unknownTags) { + mUnknownTags = unknownTags; + return this; + } + + public Builder withStartData(StartData startData) { + mStartData = startData; + return this; + } + + public MasterPlaylist build() { + return new MasterPlaylist(mPlaylists, mIFramePlaylists, mMediaData, mUnknownTags, mStartData); + } + } +} diff --git a/src/main/java/com/iheartradio/m3u8/data/MediaData.java b/src/main/java/com/iheartradio/m3u8/data/MediaData.java new file mode 100644 index 0000000..9708845 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/data/MediaData.java @@ -0,0 +1,307 @@ +package com.iheartradio.m3u8.data; + +import java.util.List; +import java.util.Objects; + +public class MediaData { + public static final int NO_CHANNELS = -1; + + private final MediaType mType; + private final String mUri; + private final String mGroupId; + private final String mLanguage; + private final String mAssociatedLanguage; + private final String mName; + private final boolean mDefault; + private final boolean mAutoSelect; + private final boolean mForced; + private final String mInStreamId; + private final List mCharacteristics; + private final int mChannels; + + private MediaData( + MediaType type, + String uri, + String groupId, + String language, + String associatedLanguage, + String name, + boolean isDefault, + boolean isAutoSelect, + boolean isForced, + String inStreamId, + List characteristics, + int channels) { + mType = type; + mUri = uri; + mGroupId = groupId; + mLanguage = language; + mAssociatedLanguage = associatedLanguage; + mName = name; + mDefault = isDefault; + mAutoSelect = isAutoSelect; + mForced = isForced; + mInStreamId = inStreamId; + mCharacteristics = DataUtil.emptyOrUnmodifiable(characteristics); + mChannels = channels; + } + + public MediaType getType() { + return mType; + } + + public boolean hasUri() { + return mUri != null && !mUri.isEmpty(); + } + + public String getUri() { + return mUri; + } + + public String getGroupId() { + return mGroupId; + } + + public boolean hasLanguage() { + return mLanguage != null; + } + + public String getLanguage() { + return mLanguage; + } + + public boolean hasAssociatedLanguage() { + return mAssociatedLanguage != null; + } + + public String getAssociatedLanguage() { + return mAssociatedLanguage; + } + + public String getName() { + return mName; + } + + public boolean isDefault() { + return mDefault; + } + + public boolean isAutoSelect() { + return mAutoSelect; + } + + public boolean isForced() { + return mForced; + } + + public boolean hasInStreamId() { + return mInStreamId != null; + } + + public String getInStreamId() { + return mInStreamId; + } + + public boolean hasCharacteristics() { + return !mCharacteristics.isEmpty(); + } + + public List getCharacteristics() { + return mCharacteristics; + } + + public boolean hasChannels() { + return mChannels != NO_CHANNELS; + } + + public Integer getChannels() { + return mChannels; + } + + public Builder buildUpon() { + return new Builder( + mType, + mUri, + mGroupId, + mLanguage, + mAssociatedLanguage, + mName, + mDefault, + mAutoSelect, + mForced, + mInStreamId, + mCharacteristics, + mChannels); + } + + @Override + public int hashCode() { + return Objects.hash( + mAssociatedLanguage, + mAutoSelect, + mCharacteristics, + mDefault, + mForced, + mGroupId, + mInStreamId, + mLanguage, + mName, + mType, + mUri, + mChannels); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof MediaData)) { + return false; + } + + MediaData other = (MediaData) o; + + return mType == other.mType && + Objects.equals(mUri, other.mUri) && + Objects.equals(mGroupId, other.mGroupId) && + Objects.equals(mLanguage, other.mLanguage) && + Objects.equals(mAssociatedLanguage, other.mAssociatedLanguage) && + Objects.equals(mName, other.mName) && + mDefault == other.mDefault && + mAutoSelect == other.mAutoSelect && + mForced == other.mForced && + Objects.equals(mInStreamId, other.mInStreamId) && + Objects.equals(mCharacteristics, other.mCharacteristics) && + mChannels == other.mChannels; + } + + public static class Builder { + private MediaType mType; + private String mUri; + private String mGroupId; + private String mLanguage; + private String mAssociatedLanguage; + private String mName; + private boolean mDefault; + private boolean mAutoSelect; + private boolean mForced; + private String mInStreamId; + private List mCharacteristics; + private int mChannels = NO_CHANNELS; + + public Builder() { + } + + private Builder( + MediaType type, + String uri, + String groupId, + String language, + String associatedLanguage, + String name, + boolean isDefault, + boolean autoSelect, + boolean forced, + String inStreamId, + List characteristics, + int channels) { + mType = type; + mUri = uri; + mGroupId = groupId; + mLanguage = language; + mAssociatedLanguage = associatedLanguage; + mName = name; + mDefault = isDefault; + mAutoSelect = autoSelect; + mForced = forced; + mInStreamId = inStreamId; + mCharacteristics = characteristics; + mChannels = channels; + } + + public Builder withType(MediaType type) { + mType = type; + return this; + } + + public Builder withUri(String uri) { + mUri = uri; + return this; + } + + public Builder withGroupId(String groupId) { + mGroupId = groupId; + return this; + } + + public Builder withLanguage(String language) { + mLanguage = language; + return this; + } + + public Builder withAssociatedLanguage(String associatedLanguage) { + mAssociatedLanguage = associatedLanguage; + return this; + } + + public Builder withName(String name) { + mName = name; + return this; + } + + public Builder withDefault(boolean isDefault) { + mDefault = isDefault; + return this; + } + + public Builder withAutoSelect(boolean isAutoSelect) { + mAutoSelect = isAutoSelect; + return this; + } + + public Builder withForced(boolean isForced) { + mForced = isForced; + return this; + } + + public Builder withInStreamId(String inStreamId) { + mInStreamId = inStreamId; + return this; + } + + public Builder withCharacteristics(List characteristics) { + mCharacteristics = characteristics; + return this; + } + + public Builder withChannels(int channels) { + mChannels = channels; + return this; + } + + public MediaData build() { + return new MediaData( + mType, + mUri, + mGroupId, + mLanguage, + mAssociatedLanguage, + mName, + mDefault, + mAutoSelect, + mForced, + mInStreamId, + mCharacteristics, + mChannels); + } + } + + @Override + public String toString() { + return "MediaData [mType=" + mType + ", mUri=" + mUri + ", mGroupId=" + + mGroupId + ", mLanguage=" + mLanguage + + ", mAssociatedLanguage=" + mAssociatedLanguage + ", mName=" + + mName + ", mDefault=" + mDefault + ", mAutoSelect=" + + mAutoSelect + ", mForced=" + mForced + ", mInStreamId=" + + mInStreamId + ", mCharacteristics=" + mCharacteristics + + ", mChannels=" + mChannels + "]"; + } +} diff --git a/src/main/java/com/iheartradio/m3u8/data/MediaPlaylist.java b/src/main/java/com/iheartradio/m3u8/data/MediaPlaylist.java new file mode 100644 index 0000000..74c689b --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/data/MediaPlaylist.java @@ -0,0 +1,210 @@ +package com.iheartradio.m3u8.data; + +import java.util.List; +import java.util.Objects; + +public class MediaPlaylist { + private final List mTracks; + private final List mUnknownTags; + private final int mTargetDuration; + private final int mMediaSequenceNumber; + private final boolean mIsIframesOnly; + private final boolean mIsOngoing; + private final PlaylistType mPlaylistType; + private final StartData mStartData; + + private MediaPlaylist(List tracks, List unknownTags, int targetDuration, StartData startData, int mediaSequenceNumber, boolean isIframesOnly, boolean isOngoing, PlaylistType playlistType) { + mTracks = DataUtil.emptyOrUnmodifiable(tracks); + mUnknownTags = DataUtil.emptyOrUnmodifiable(unknownTags); + mTargetDuration = targetDuration; + mMediaSequenceNumber = mediaSequenceNumber; + mIsIframesOnly = isIframesOnly; + mIsOngoing = isOngoing; + mStartData = startData; + mPlaylistType = playlistType; + } + + public boolean hasTracks() { + return !mTracks.isEmpty(); + } + + public List getTracks() { + return mTracks; + } + + public int getTargetDuration() { + return mTargetDuration; + } + + public int getMediaSequenceNumber() { + return mMediaSequenceNumber; + } + + public boolean isIframesOnly() { + return mIsIframesOnly; + } + + public boolean isOngoing() { + return mIsOngoing; + } + + public boolean hasUnknownTags() { + return !mUnknownTags.isEmpty(); + } + + public List getUnknownTags() { + return mUnknownTags; + } + + public StartData getStartData() { + return mStartData; + } + + public boolean hasStartData() { + return mStartData != null; + } + + public PlaylistType getPlaylistType() { + return mPlaylistType; + } + + public boolean hasPlaylistType() { + return mPlaylistType != null; + } + + public int getDiscontinuitySequenceNumber(final int segmentIndex) { + if (segmentIndex < 0 || segmentIndex >= mTracks.size()) { + throw new IndexOutOfBoundsException(); + } + + int discontinuitySequenceNumber = 0; + + for (int i = 0; i <= segmentIndex; ++i) { + if (mTracks.get(i).hasDiscontinuity()) { + ++discontinuitySequenceNumber; + } + } + + return discontinuitySequenceNumber; + } + + public Builder buildUpon() { + return new Builder(mTracks, mUnknownTags, mTargetDuration, mMediaSequenceNumber, mIsIframesOnly, mIsOngoing, mPlaylistType, mStartData); + } + + @Override + public int hashCode() { + return Objects.hash( + mTracks, + mUnknownTags, + mTargetDuration, + mMediaSequenceNumber, + mIsIframesOnly, + mIsOngoing, + mPlaylistType, + mStartData); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof MediaPlaylist)) { + return false; + } + + MediaPlaylist other = (MediaPlaylist) o; + + return Objects.equals(mTracks, other.mTracks) && + Objects.equals(mUnknownTags, other.mUnknownTags) && + mTargetDuration == other.mTargetDuration && + mMediaSequenceNumber == other.mMediaSequenceNumber && + mIsIframesOnly == other.mIsIframesOnly && + mIsOngoing == other.mIsOngoing && + Objects.equals(mPlaylistType, other.mPlaylistType) && + Objects.equals(mStartData, other.mStartData); + } + + @Override + public String toString() { + return new StringBuilder() + .append("(MediaPlaylist") + .append(" mTracks=").append(mTracks) + .append(" mUnknownTags=").append(mUnknownTags) + .append(" mTargetDuration=").append(mTargetDuration) + .append(" mMediaSequenceNumber=").append(mMediaSequenceNumber) + .append(" mIsIframesOnly=").append(mIsIframesOnly) + .append(" mIsOngoing=").append(mIsOngoing) + .append(" mPlaylistType=").append(mPlaylistType) + .append(" mStartData=").append(mStartData) + .append(")") + .toString(); + } + + public static class Builder { + private List mTracks; + private List mUnknownTags; + private int mTargetDuration; + private int mMediaSequenceNumber; + private boolean mIsIframesOnly; + private boolean mIsOngoing; + private PlaylistType mPlaylistType; + private StartData mStartData; + + public Builder() { + } + + private Builder(List tracks, List unknownTags, int targetDuration, int mediaSequenceNumber, boolean isIframesOnly, boolean isOngoing, PlaylistType playlistType, StartData startData) { + mTracks = tracks; + mUnknownTags = unknownTags; + mTargetDuration = targetDuration; + mMediaSequenceNumber = mediaSequenceNumber; + mIsIframesOnly = isIframesOnly; + mIsOngoing = isOngoing; + mPlaylistType = playlistType; + mStartData = startData; + } + + public Builder withTracks(List tracks) { + mTracks = tracks; + return this; + } + + public Builder withUnknownTags(List unknownTags) { + mUnknownTags = unknownTags; + return this; + } + + public Builder withTargetDuration(int targetDuration) { + mTargetDuration = targetDuration; + return this; + } + + public Builder withStartData(StartData startData) { + mStartData = startData; + return this; + } + + public Builder withMediaSequenceNumber(int mediaSequenceNumber) { + mMediaSequenceNumber = mediaSequenceNumber; + return this; + } + + public Builder withIsIframesOnly(boolean isIframesOnly) { + mIsIframesOnly = isIframesOnly; + return this; + } + + public Builder withIsOngoing(boolean isOngoing) { + mIsOngoing = isOngoing; + return this; + } + + public Builder withPlaylistType(PlaylistType playlistType) { + mPlaylistType = playlistType; + return this; + } + + public MediaPlaylist build() { + return new MediaPlaylist(mTracks, mUnknownTags, mTargetDuration, mStartData, mMediaSequenceNumber, mIsIframesOnly, mIsOngoing, mPlaylistType); + } + } +} diff --git a/src/main/java/com/iheartradio/m3u8/data/MediaType.java b/src/main/java/com/iheartradio/m3u8/data/MediaType.java new file mode 100644 index 0000000..efe6947 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/data/MediaType.java @@ -0,0 +1,33 @@ +package com.iheartradio.m3u8.data; + +import java.util.HashMap; +import java.util.Map; + +public enum MediaType { + AUDIO("AUDIO"), + VIDEO("VIDEO"), + SUBTITLES("SUBTITLES"), + CLOSED_CAPTIONS("CLOSED-CAPTIONS"); + + private static final Map sMap = new HashMap(); + + private final String value; + + static { + for (MediaType mediaType : MediaType.values()) { + sMap.put(mediaType.value, mediaType); + } + } + + private MediaType(String value) { + this.value = value; + } + + public static MediaType fromValue(String value) { + return sMap.get(value); + } + + public String getValue() { + return value; + } +} diff --git a/src/main/java/com/iheartradio/m3u8/data/Playlist.java b/src/main/java/com/iheartradio/m3u8/data/Playlist.java new file mode 100644 index 0000000..5b394bc --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/data/Playlist.java @@ -0,0 +1,119 @@ +package com.iheartradio.m3u8.data; + +import java.util.Objects; + +public class Playlist { + public static final int MIN_COMPATIBILITY_VERSION = 1; + + private final MasterPlaylist mMasterPlaylist; + private final MediaPlaylist mMediaPlaylist; + private final boolean mIsExtended; + private final int mCompatibilityVersion; + + private Playlist(MasterPlaylist masterPlaylist, MediaPlaylist mediaPlaylist, boolean isExtended, int compatibilityVersion) { + mMasterPlaylist = masterPlaylist; + mMediaPlaylist = mediaPlaylist; + mIsExtended = isExtended; + mCompatibilityVersion = compatibilityVersion; + } + + public boolean hasMasterPlaylist() { + return mMasterPlaylist != null; + } + + public boolean hasMediaPlaylist() { + return mMediaPlaylist != null; + } + + public MasterPlaylist getMasterPlaylist() { + return mMasterPlaylist; + } + + public MediaPlaylist getMediaPlaylist() { + return mMediaPlaylist; + } + + public boolean isExtended() { + return mIsExtended; + } + + public int getCompatibilityVersion() { + return mCompatibilityVersion; + } + + public Builder buildUpon() { + return new Builder(mMasterPlaylist, mMediaPlaylist, mIsExtended, mCompatibilityVersion); + } + + @Override + public int hashCode() { + return Objects.hash(mCompatibilityVersion, mIsExtended, mMasterPlaylist, mMediaPlaylist); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Playlist)) { + return false; + } + + Playlist other = (Playlist) o; + + return Objects.equals(mMasterPlaylist, other.mMasterPlaylist) && + Objects.equals(mMediaPlaylist, other.mMediaPlaylist) && + mIsExtended == other.mIsExtended && + mCompatibilityVersion == other.mCompatibilityVersion; + } + + @Override + public String toString() { + return new StringBuilder() + .append("(Playlist") + .append(" mMasterPlaylist=").append(mMasterPlaylist) + .append(" mMediaPlaylist=").append(mMediaPlaylist) + .append(" mIsExtended=").append(mIsExtended) + .append(" mCompatibilityVersion=").append(mCompatibilityVersion) + .append(")") + .toString(); + } + + public static class Builder { + private MasterPlaylist mMasterPlaylist; + private MediaPlaylist mMediaPlaylist; + private boolean mIsExtended; + private int mCompatibilityVersion = MIN_COMPATIBILITY_VERSION; + + public Builder() { + } + + private Builder(MasterPlaylist masterPlaylist, MediaPlaylist mediaPlaylist, boolean isExtended, int compatibilityVersion) { + mMasterPlaylist = masterPlaylist; + mMediaPlaylist = mediaPlaylist; + mIsExtended = isExtended; + mCompatibilityVersion = compatibilityVersion; + } + + public Builder withMasterPlaylist(MasterPlaylist masterPlaylist) { + mMasterPlaylist = masterPlaylist; + return withExtended(true); + } + + public Builder withMediaPlaylist(MediaPlaylist mediaPlaylist) { + mMediaPlaylist = mediaPlaylist; + return this; + } + + public Builder withExtended(boolean isExtended) { + mIsExtended = isExtended; + return this; + } + + public Builder withCompatibilityVersion(int version) { + mCompatibilityVersion = version; + return this; + } + + public Playlist build() { + return new Playlist(mMasterPlaylist, mMediaPlaylist, mIsExtended, mCompatibilityVersion); + } + } +} diff --git a/src/main/java/com/iheartradio/m3u8/data/PlaylistData.java b/src/main/java/com/iheartradio/m3u8/data/PlaylistData.java new file mode 100644 index 0000000..320c148 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/data/PlaylistData.java @@ -0,0 +1,82 @@ +package com.iheartradio.m3u8.data; + +import java.util.Objects; + +public class PlaylistData { + private final String mUri; + private final StreamInfo mStreamInfo; + + private PlaylistData(String uri, StreamInfo streamInfo) { + mUri = uri; + mStreamInfo = streamInfo; + } + + public String getUri() { + return mUri; + } + + public boolean hasStreamInfo() { + return mStreamInfo != null; + } + + public StreamInfo getStreamInfo() { + return mStreamInfo; + } + + public Builder buildUpon() { + return new Builder(mUri, mStreamInfo); + } + + @Override + public int hashCode() { + return Objects.hash(mUri, mStreamInfo); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof PlaylistData)) { + return false; + } + + PlaylistData other = (PlaylistData) o; + return Objects.equals(mUri, other.mUri) && Objects.equals(mStreamInfo, other.mStreamInfo); + } + + @Override + public String toString() { + return "PlaylistData [mStreamInfo=" + mStreamInfo + + ", mUri=" + mUri + "]"; + } + + public static class Builder { + private String mUri; + private StreamInfo mStreamInfo; + + public Builder() { + } + + private Builder(String uri, StreamInfo streamInfo) { + mUri = uri; + mStreamInfo = streamInfo; + } + + public Builder withPath(String path) { + mUri = path; + return this; + } + + public Builder withUri(String uri) { + mUri = uri; + return this; + } + + public Builder withStreamInfo(StreamInfo streamInfo) { + mStreamInfo = streamInfo; + return this; + } + + public PlaylistData build() { + return new PlaylistData(mUri, mStreamInfo); + } + } +} diff --git a/src/main/java/com/iheartradio/m3u8/data/PlaylistType.java b/src/main/java/com/iheartradio/m3u8/data/PlaylistType.java new file mode 100644 index 0000000..157671a --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/data/PlaylistType.java @@ -0,0 +1,30 @@ +package com.iheartradio.m3u8.data; + +import java.util.HashMap; +import java.util.Map; + +public enum PlaylistType { + EVENT("EVENT"), VOD("VOD"); + + private static final Map sMap = new HashMap(); + + private final String value; + + static { + for (PlaylistType mediaType : PlaylistType.values()) { + sMap.put(mediaType.value, mediaType); + } + } + + private PlaylistType(String value) { + this.value = value; + } + + public static PlaylistType fromValue(String value) { + return sMap.get(value); + } + + public String getValue() { + return value; + } +} diff --git a/src/main/java/com/iheartradio/m3u8/data/Resolution.java b/src/main/java/com/iheartradio/m3u8/data/Resolution.java new file mode 100644 index 0000000..e9d4989 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/data/Resolution.java @@ -0,0 +1,30 @@ +package com.iheartradio.m3u8.data; + +import java.util.Objects; + +public class Resolution { + public final int width; + public final int height; + + public Resolution(int width, int height) { + this.width = width; + this.height = height; + } + + @Override + public int hashCode() { + return Objects.hash(height, width); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Resolution)) { + return false; + } + + Resolution other = (Resolution) o; + + return width == other.width && + height == other.height; + } +} diff --git a/src/main/java/com/iheartradio/m3u8/data/StartData.java b/src/main/java/com/iheartradio/m3u8/data/StartData.java new file mode 100644 index 0000000..487b1be --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/data/StartData.java @@ -0,0 +1,69 @@ +package com.iheartradio.m3u8.data; + +import java.util.Objects; + +public class StartData { + private final float mTimeOffset; + private final boolean mPrecise; + + public StartData(float timeOffset, boolean precise) { + mTimeOffset = timeOffset; + mPrecise = precise; + } + + public float getTimeOffset() { + return mTimeOffset; + } + + public boolean isPrecise() { + return mPrecise; + } + + public Builder buildUpon() { + return new Builder(mTimeOffset, mPrecise); + } + + @Override + public int hashCode() { + return Objects.hash(mPrecise, mTimeOffset); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof StartData)) { + return false; + } + + StartData other = (StartData) o; + + return this.mPrecise == other.mPrecise && + this.mTimeOffset == other.mTimeOffset; + } + + public static class Builder { + private float mTimeOffset = Float.NaN; + private boolean mPrecise; + + public Builder() { + } + + private Builder(float timeOffset, boolean precise) { + mTimeOffset = timeOffset; + mPrecise = precise; + } + + public Builder withTimeOffset(float timeOffset) { + mTimeOffset = timeOffset; + return this; + } + + public Builder withPrecise(boolean precise) { + mPrecise = precise; + return this; + } + + public StartData build() { + return new StartData(mTimeOffset, mPrecise); + } + } +} diff --git a/src/main/java/com/iheartradio/m3u8/data/StreamInfo.java b/src/main/java/com/iheartradio/m3u8/data/StreamInfo.java new file mode 100644 index 0000000..60d67d7 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/data/StreamInfo.java @@ -0,0 +1,264 @@ +package com.iheartradio.m3u8.data; + +import java.util.List; +import java.util.Objects; + +public class StreamInfo implements IStreamInfo { + public static final int NO_BANDWIDTH = -1; + + private final int mBandwidth; + private final int mAverageBandwidth; + private final List mCodecs; + private final Resolution mResolution; + private final float mFrameRate; + private final String mAudio; + private final String mVideo; + private final String mSubtitles; + private final String mClosedCaptions; + + private StreamInfo( + int bandwidth, + int averageBandwidth, + List codecs, + Resolution resolution, + float frameRate, + String audio, + String video, + String subtitles, + String closedCaptions) { + mBandwidth = bandwidth; + mAverageBandwidth = averageBandwidth; + mCodecs = codecs; + mResolution = resolution; + mFrameRate = frameRate; + mAudio = audio; + mVideo = video; + mSubtitles = subtitles; + mClosedCaptions = closedCaptions; + } + + @Override + public int getBandwidth() { + return mBandwidth; + } + + @Override + public boolean hasAverageBandwidth() { + return mAverageBandwidth != NO_BANDWIDTH; + } + + @Override + public int getAverageBandwidth() { + return mAverageBandwidth; + } + + @Override + public boolean hasCodecs() { + return mCodecs != null; + } + + @Override + public List getCodecs() { + return mCodecs; + } + + @Override + public boolean hasResolution() { + return mResolution != null; + } + + @Override + public Resolution getResolution() { + return mResolution; + } + + @Override + public boolean hasFrameRate() { + return !Float.isNaN(mFrameRate); + } + + @Override + public float getFrameRate() { + return mFrameRate; + } + + public boolean hasAudio() { + return mAudio != null; + } + + public String getAudio() { + return mAudio; + } + + @Override + public boolean hasVideo() { + return mVideo != null; + } + + @Override + public String getVideo() { + return mVideo; + } + + public boolean hasSubtitles() { + return mSubtitles != null; + } + + public String getSubtitles() { + return mSubtitles; + } + + public boolean hasClosedCaptions() { + return mClosedCaptions != null; + } + + public String getClosedCaptions() { + return mClosedCaptions; + } + + public Builder buildUpon() { + return new Builder( + mBandwidth, + mAverageBandwidth, + mCodecs, + mResolution, + mFrameRate, + mAudio, + mVideo, + mSubtitles, + mClosedCaptions); + } + + @Override + public int hashCode() { + return Objects.hash( + mBandwidth, + mAverageBandwidth, + mCodecs, + mResolution, + mFrameRate, + mAudio, + mVideo, + mSubtitles, + mClosedCaptions); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof StreamInfo)) { + return false; + } + + StreamInfo other = (StreamInfo) o; + + return mBandwidth == other.mBandwidth && + mAverageBandwidth == other.mAverageBandwidth && + Objects.equals(mCodecs, other.mCodecs) && + Objects.equals(mResolution, other.mResolution) && + Objects.equals(mFrameRate, other.mFrameRate) && + Objects.equals(mAudio, other.mAudio) && + Objects.equals(mVideo, other.mVideo) && + Objects.equals(mSubtitles, other.mSubtitles) && + Objects.equals(mClosedCaptions, other.mClosedCaptions); + } + + public static class Builder implements StreamInfoBuilder { + private int mBandwidth = NO_BANDWIDTH; + private int mAverageBandwidth = NO_BANDWIDTH; + private List mCodecs; + private Resolution mResolution; + private float mFrameRate = Float.NaN; + private String mAudio; + private String mVideo; + private String mSubtitles; + private String mClosedCaptions; + + public Builder() { + } + + private Builder( + int bandwidth, + int averageBandwidth, + List codecs, + Resolution resolution, + float frameRate, + String audio, + String video, + String subtitles, + String closedCaptions) { + mBandwidth = bandwidth; + mAverageBandwidth = averageBandwidth; + mCodecs = codecs; + mResolution = resolution; + mFrameRate = frameRate; + mAudio = audio; + mVideo = video; + mSubtitles = subtitles; + mClosedCaptions = closedCaptions; + } + + @Override + public Builder withBandwidth(int bandwidth) { + mBandwidth = bandwidth; + return this; + } + + @Override + public Builder withAverageBandwidth(int averageBandwidth) { + mAverageBandwidth = averageBandwidth; + return this; + } + + @Override + public Builder withCodecs(List codecs) { + mCodecs = codecs; + return this; + } + + @Override + public Builder withResolution(Resolution resolution) { + mResolution = resolution; + return this; + } + + @Override + public Builder withFrameRate(float frameRate) { + mFrameRate = frameRate; + return this; + } + + public Builder withAudio(String audio) { + mAudio = audio; + return this; + } + + @Override + public Builder withVideo(String video) { + mVideo = video; + return this; + } + + public Builder withSubtitles(String subtitles) { + mSubtitles = subtitles; + return this; + } + + public Builder withClosedCaptions(String closedCaptions) { + mClosedCaptions = closedCaptions; + return this; + } + + public StreamInfo build() { + return new StreamInfo( + mBandwidth, + mAverageBandwidth, + mCodecs, + mResolution, + mFrameRate, + mAudio, + mVideo, + mSubtitles, + mClosedCaptions); + } + } +} diff --git a/src/main/java/com/iheartradio/m3u8/data/StreamInfoBuilder.java b/src/main/java/com/iheartradio/m3u8/data/StreamInfoBuilder.java new file mode 100644 index 0000000..bb75771 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/data/StreamInfoBuilder.java @@ -0,0 +1,17 @@ +package com.iheartradio.m3u8.data; + +import java.util.List; + +public interface StreamInfoBuilder { + public StreamInfoBuilder withBandwidth(int bandwidth); + + public StreamInfoBuilder withAverageBandwidth(int averageBandwidth); + + public StreamInfoBuilder withCodecs(List codecs); + + public StreamInfoBuilder withResolution(Resolution resolution); + + public StreamInfoBuilder withFrameRate(float frameRate); + + public StreamInfoBuilder withVideo(String video); +} diff --git a/src/main/java/com/iheartradio/m3u8/data/TrackData.java b/src/main/java/com/iheartradio/m3u8/data/TrackData.java new file mode 100644 index 0000000..8fa7c31 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/data/TrackData.java @@ -0,0 +1,174 @@ +package com.iheartradio.m3u8.data; + +import java.util.Objects; + +public class TrackData { + private final String mUri; + private final TrackInfo mTrackInfo; + private final EncryptionData mEncryptionData; + private final String mProgramDateTime; + private final boolean mHasDiscontinuity; + private final MapInfo mMapInfo; + private final ByteRange mByteRange; + + private TrackData(String uri, TrackInfo trackInfo, EncryptionData encryptionData, String programDateTime, boolean hasDiscontinuity, MapInfo mapInfo, ByteRange byteRange) { + mUri = uri; + mTrackInfo = trackInfo; + mEncryptionData = encryptionData; + mProgramDateTime = programDateTime; + mHasDiscontinuity = hasDiscontinuity; + mMapInfo = mapInfo; + mByteRange = byteRange; + } + + public String getUri() { + return mUri; + } + + public boolean hasTrackInfo() { + return mTrackInfo != null; + } + + public TrackInfo getTrackInfo() { + return mTrackInfo; + } + + public boolean hasEncryptionData() { + return mEncryptionData != null; + } + + public boolean isEncrypted() { + return hasEncryptionData() && + mEncryptionData.getMethod() != null && + mEncryptionData.getMethod() != EncryptionMethod.NONE; + } + + public boolean hasProgramDateTime() { + return mProgramDateTime != null && mProgramDateTime.length() > 0; + } + + public String getProgramDateTime() { + return mProgramDateTime; + } + + public boolean hasDiscontinuity() { + return mHasDiscontinuity; + } + + public EncryptionData getEncryptionData() { + return mEncryptionData; + } + + public boolean hasMapInfo() { + return mMapInfo != null; + } + + public MapInfo getMapInfo() { + return mMapInfo; + } + + public boolean hasByteRange() { + return mByteRange != null; + } + + public ByteRange getByteRange() { + return mByteRange; + } + + public Builder buildUpon() { + return new Builder(getUri(), mTrackInfo, mEncryptionData, mHasDiscontinuity, mMapInfo, mByteRange); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TrackData trackData = (TrackData) o; + return mHasDiscontinuity == trackData.mHasDiscontinuity && + Objects.equals(mUri, trackData.mUri) && + Objects.equals(mTrackInfo, trackData.mTrackInfo) && + Objects.equals(mEncryptionData, trackData.mEncryptionData) && + Objects.equals(mProgramDateTime, trackData.mProgramDateTime) && + Objects.equals(mMapInfo, trackData.mMapInfo) && + Objects.equals(mByteRange, trackData.mByteRange); + } + + @Override + public int hashCode() { + return Objects.hash(mUri, mTrackInfo, mEncryptionData, mProgramDateTime, mHasDiscontinuity, mMapInfo, mByteRange); + } + + @Override + public String toString() { + return "TrackData{" + + "mUri='" + mUri + '\'' + + ", mTrackInfo=" + mTrackInfo + + ", mEncryptionData=" + mEncryptionData + + ", mProgramDateTime='" + mProgramDateTime + '\'' + + ", mHasDiscontinuity=" + mHasDiscontinuity + + ", mMapInfo=" + mMapInfo + + ", mByteRange=" + mByteRange + + '}'; + } + + public static class Builder { + private String mUri; + private TrackInfo mTrackInfo; + private EncryptionData mEncryptionData; + private String mProgramDateTime; + private boolean mHasDiscontinuity; + private MapInfo mMapInfo; + private ByteRange mByteRange; + + public Builder() { + } + + private Builder(String uri, TrackInfo trackInfo, EncryptionData encryptionData, boolean hasDiscontinuity, MapInfo mapInfo, ByteRange byteRange) { + mUri = uri; + mTrackInfo = trackInfo; + mEncryptionData = encryptionData; + mHasDiscontinuity = hasDiscontinuity; + mMapInfo = mapInfo; + mByteRange = byteRange; + } + + public Builder withUri(String url) { + mUri = url; + return this; + } + + public Builder withTrackInfo(TrackInfo trackInfo) { + mTrackInfo = trackInfo; + return this; + } + + public Builder withEncryptionData(EncryptionData encryptionData) { + mEncryptionData = encryptionData; + return this; + } + + public Builder withProgramDateTime(String programDateTime) { + mProgramDateTime = programDateTime; + return this; + } + + public Builder withDiscontinuity(boolean hasDiscontinuity) { + mHasDiscontinuity = hasDiscontinuity; + return this; + } + + public Builder withMapInfo(MapInfo mapInfo) { + mMapInfo = mapInfo; + return this; + } + + public Builder withByteRange(ByteRange byteRange) { + mByteRange = byteRange; + return this; + } + + public TrackData build() { + return new TrackData(mUri, mTrackInfo, mEncryptionData, mProgramDateTime, mHasDiscontinuity, mMapInfo, mByteRange); + } + } +} diff --git a/src/main/java/com/iheartradio/m3u8/data/TrackInfo.java b/src/main/java/com/iheartradio/m3u8/data/TrackInfo.java new file mode 100644 index 0000000..8466dae --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/data/TrackInfo.java @@ -0,0 +1,31 @@ +package com.iheartradio.m3u8.data; + +import java.util.Objects; + +public class TrackInfo { + public final float duration; + public final String title; + + public TrackInfo(float duration, String title) { + this.duration = duration; + this.title = title; + } + + @Override + public int hashCode() { + return Objects.hash(duration, title); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof TrackInfo)) { + return false; + } + + TrackInfo other = (TrackInfo) o; + + return this.duration == other.duration && + Objects.equals(this.title, other.title); + } + +} diff --git a/src/test/java/com/iheartradio/m3u8/ByteOrderMarkTest.java b/src/test/java/com/iheartradio/m3u8/ByteOrderMarkTest.java new file mode 100644 index 0000000..e75b702 --- /dev/null +++ b/src/test/java/com/iheartradio/m3u8/ByteOrderMarkTest.java @@ -0,0 +1,76 @@ +package com.iheartradio.m3u8; + +import com.iheartradio.m3u8.data.Playlist; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; + +import static com.iheartradio.m3u8.TestUtil.inputStreamFromResource; +import static com.iheartradio.m3u8.Constants.UTF_8_BOM_BYTES; +import static org.junit.Assert.*; + +public class ByteOrderMarkTest { + @Test + public void testParsingByteOrderMark() throws Exception { + try (final InputStream inputStream = wrapWithByteOrderMark(inputStreamFromResource("simpleMediaPlaylist.m3u8"))) { + final PlaylistParser playlistParser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8); + final Playlist playlist = playlistParser.parse(); + assertEquals(10, playlist.getMediaPlaylist().getTargetDuration()); + } + } + + @SuppressWarnings("deprecation") + @Test + public void testWritingByteOrderMark() throws Exception { + final Playlist playlist1; + final Playlist playlist2; + final String written; + + try (final InputStream inputStream = inputStreamFromResource("simpleMediaPlaylist.m3u8")) { + playlist1 = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8).parse(); + } + + try (ByteArrayOutputStream os = new ByteArrayOutputStream()) { + final PlaylistWriter writer = new PlaylistWriter.Builder() + .withOutputStream(os) + .withFormat(Format.EXT_M3U) + .withEncoding(Encoding.UTF_8) + .useByteOrderMark() + .build(); + + writer.write(playlist1); + written = os.toString(Encoding.UTF_8.value); + } + + assertEquals(Constants.UNICODE_BOM, written.charAt(0)); + + try (final InputStream inputStream = new ByteArrayInputStream(written.getBytes(Encoding.UTF_8.value))) { + playlist2 = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8).parse(); + } + + assertEquals(playlist1, playlist2); + } + + private static InputStream wrapWithByteOrderMark(final InputStream inputStream) { + return new InputStream() { + public int mNumRead; + + @Override + public int read() throws IOException { + if (UTF_8_BOM_BYTES.length > mNumRead) { + return UTF_8_BOM_BYTES[mNumRead++]; + } else { + return inputStream.read(); + } + } + + @Override + public void close() throws IOException { + inputStream.close(); + } + }; + } +} diff --git a/src/test/java/com/iheartradio/m3u8/ExtLineParserTest.java b/src/test/java/com/iheartradio/m3u8/ExtLineParserTest.java new file mode 100644 index 0000000..0b9b1c5 --- /dev/null +++ b/src/test/java/com/iheartradio/m3u8/ExtLineParserTest.java @@ -0,0 +1,53 @@ +package com.iheartradio.m3u8; + +import org.junit.Test; + +public class ExtLineParserTest extends LineParserStateTestCase { + @Test + public void testEXTM3U() throws Exception { + final IExtTagParser handler = ExtLineParser.EXTM3U_HANDLER; + final String tag = Constants.EXTM3U_TAG; + final String line = "#" + tag; + + assertEquals(tag, handler.getTag()); + + handler.parse(line, mParseState); + assertTrue(mParseState.isExtended()); + + assertParseThrows(handler, line, ParseExceptionType.MULTIPLE_EXT_TAG_INSTANCES); + } + + @Test + public void testEXT_X_VERSION() throws Exception { + final IExtTagParser handler = ExtLineParser.EXT_X_VERSION_HANDLER; + final String tag = Constants.EXT_X_VERSION_TAG; + final String line = "#" + tag + ":2"; + + assertEquals(tag, handler.getTag()); + + assertParseThrows(handler, line + ".", ParseExceptionType.BAD_EXT_TAG_FORMAT); + + handler.parse(line, mParseState); + assertEquals(2, mParseState.getCompatibilityVersion()); + + assertParseThrows(handler, line, ParseExceptionType.MULTIPLE_EXT_TAG_INSTANCES); + } + + @Test + public void testEXT_X_START() throws Exception { + final IExtTagParser parser = ExtLineParser.EXT_X_START; + final String tag = Constants.EXT_X_START_TAG; + final String line = "#" + tag + + ":TIME-OFFSET=-4.5" + + ",PRECISE=YES"; + + assertEquals(tag, parser.getTag()); + assertParseThrows(parser, line + ".", ParseExceptionType.NOT_YES_OR_NO); + + parser.parse(line, mParseState); + assertEquals(-4.5, mParseState.startData.getTimeOffset(), 1e-12); + assertEquals(true, mParseState.startData.isPrecise()); + + assertParseThrows(parser, line, ParseExceptionType.MULTIPLE_EXT_TAG_INSTANCES); + } +} diff --git a/src/test/java/com/iheartradio/m3u8/ExtendedM3uParserTest.java b/src/test/java/com/iheartradio/m3u8/ExtendedM3uParserTest.java new file mode 100644 index 0000000..09c57f1 --- /dev/null +++ b/src/test/java/com/iheartradio/m3u8/ExtendedM3uParserTest.java @@ -0,0 +1,183 @@ +package com.iheartradio.m3u8; + +import com.iheartradio.m3u8.data.MediaData; +import com.iheartradio.m3u8.data.MediaPlaylist; +import com.iheartradio.m3u8.data.MediaType; +import com.iheartradio.m3u8.data.Playlist; +import com.iheartradio.m3u8.data.StreamInfo; +import com.iheartradio.m3u8.data.TrackData; +import com.iheartradio.m3u8.data.TrackInfo; + +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static com.iheartradio.m3u8.TestUtil.inputStreamFromResource; +import static org.junit.Assert.*; + +public class ExtendedM3uParserTest { + @Test + public void testParseMaster() throws Exception { + final List expectedMediaData = new ArrayList(); + + expectedMediaData.add(new MediaData.Builder() + .withType(MediaType.AUDIO) + .withGroupId("1234") + .withName("Foo") + .build()); + + final StreamInfo expectedStreamInfo = new StreamInfo.Builder() + .withBandwidth(500) + .build(); + + final String validData = + "#EXTM3U\n" + + "#EXT-X-VERSION:2\n" + + "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"1234\",NAME=\"Foo\"\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=500\n" + + "http://foo.bar.com/\n" + + "\n"; + + final InputStream inputStream = new ByteArrayInputStream(validData.getBytes("utf-8")); + final ExtendedM3uParser parser = new ExtendedM3uParser(inputStream, Encoding.UTF_8, ParsingMode.STRICT); + + assertTrue(parser.isAvailable()); + + final Playlist playlist = parser.parse(); + + assertFalse(parser.isAvailable()); + assertTrue(playlist.isExtended()); + assertEquals(2, playlist.getCompatibilityVersion()); + assertTrue(playlist.hasMasterPlaylist()); + assertEquals(expectedMediaData, playlist.getMasterPlaylist().getMediaData()); + assertEquals(expectedStreamInfo, playlist.getMasterPlaylist().getPlaylists().get(0).getStreamInfo()); + } + + @Test + public void testLenientParsing() throws Exception { + final String validData = + "#EXTM3U\n" + + "#EXT-X-VERSION:2\n" + + "#EXT-X-TARGETDURATION:60\n" + + "#EXT-X-MEDIA-SEQUENCE:10\n" + + "#EXT-FAXS-CM:MIIa4QYJKoZIhvcNAQcCoIIa0jCCGs4C...\n" + + "#some comment\n" + + "#EXTINF:120.0,title 1\n" + + "http://www.my.song/file1.mp3\n" + + "\n"; + + final InputStream inputStream = new ByteArrayInputStream(validData.getBytes("utf-8")); + final Playlist playlist = new ExtendedM3uParser(inputStream, Encoding.UTF_8, ParsingMode.LENIENT).parse(); + + assertTrue(playlist.isExtended()); + assertTrue(playlist.getMediaPlaylist().hasUnknownTags()); + assertTrue(playlist.getMediaPlaylist().getUnknownTags().get(0).length() > 0); + } + + @Test + public void testParseMedia() throws Exception { + final String absolute = "http://www.my.song/file1.mp3"; + final String relative = "user1/file2.mp3"; + + final String validData = + "#EXTM3U\n" + + "#EXT-X-VERSION:2\n" + + "#EXT-X-TARGETDURATION:60\n" + + "#EXT-X-MEDIA-SEQUENCE:10\n" + + "#some comment\n" + + "#EXTINF:120.0,title 1\n" + + absolute + "\n" + + "#EXTINF:100.0,title 2\n" + + "#EXT-X-PROGRAM-DATE-TIME:2010-02-19T14:54:23.031+08:00\n" + + "\n" + + relative + "\n" + + "\n"; + + final List expectedTracks = Arrays.asList( + new TrackData.Builder().withUri(absolute).withTrackInfo(new TrackInfo(120, "title 1")).build(), + new TrackData.Builder().withUri(relative).withTrackInfo(new TrackInfo(100, "title 2")).withProgramDateTime("2010-02-19T14:54:23.031+08:00").build()); + + final InputStream inputStream = new ByteArrayInputStream(validData.getBytes("utf-8")); + final Playlist playlist = new ExtendedM3uParser(inputStream, Encoding.UTF_8, ParsingMode.STRICT).parse(); + + assertTrue(playlist.isExtended()); + assertEquals(2, playlist.getCompatibilityVersion()); + assertTrue(playlist.hasMediaPlaylist()); + assertEquals(60, playlist.getMediaPlaylist().getTargetDuration()); + assertEquals(10, playlist.getMediaPlaylist().getMediaSequenceNumber()); + assertEquals(expectedTracks, playlist.getMediaPlaylist().getTracks()); + } + + @Test + public void testParsingMultiplePlaylists() throws Exception { + try (final InputStream inputStream = inputStreamFromResource("twoMediaPlaylists.m3u8")) { + final PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8); + + assertTrue(parser.isAvailable()); + + final Playlist playlist1 = parser.parse(); + + assertTrue(parser.isAvailable()); + + final Playlist playlist2 = parser.parse(); + + assertFalse(parser.isAvailable()); + + List expected1 = Arrays.asList( + makeTrackData("http://media.example.com/first.ts", 9.009f), + makeTrackData("http://media.example.com/second.ts", 9.009f), + makeTrackData("http://media.example.com/third.ts", 3.003f)); + + assertEquals( + expected1, + playlist1.getMediaPlaylist().getTracks()); + + assertEquals( + Arrays.asList( + makeTrackData("http://media.example.com/fourth.ts", 9.01f), + makeTrackData("http://media.example.com/fifth.ts", 9.011f)), + playlist2.getMediaPlaylist().getTracks()); + + assertEquals(0, inputStream.available()); + } + } + + @Test + public void testParseDiscontinuity() throws Exception { + final String absolute = "http://www.my.song/file1.mp3"; + final String relative = "user1/file2.mp3"; + + final String validData = + "#EXTM3U\n" + + "#EXT-X-VERSION:2\n" + + "#EXT-X-TARGETDURATION:60\n" + + "#EXT-X-MEDIA-SEQUENCE:10\n" + + "#some comment\n" + + "#EXTINF:120.0,title 1\n" + + absolute + "\n" + + "#EXT-X-DISCONTINUITY\n" + + "#EXTINF:100.0,title 2\n" + + "\n" + + relative + "\n" + + "\n"; + + final InputStream inputStream = new ByteArrayInputStream(validData.getBytes("utf-8")); + final MediaPlaylist mediaPlaylist = new ExtendedM3uParser(inputStream, Encoding.UTF_8, ParsingMode.STRICT).parse().getMediaPlaylist(); + + assertFalse(mediaPlaylist.getTracks().get(0).hasDiscontinuity()); + assertTrue(mediaPlaylist.getTracks().get(1).hasDiscontinuity()); + assertEquals(0, mediaPlaylist.getDiscontinuitySequenceNumber(0)); + assertEquals(1, mediaPlaylist.getDiscontinuitySequenceNumber(1)); + } + + private static TrackData makeTrackData(String uri, float duration) { + return new TrackData.Builder() + .withTrackInfo(new TrackInfo(duration, null)) + .withUri(uri) + .build(); + } +} diff --git a/src/test/java/com/iheartradio/m3u8/LineParserStateTestCase.java b/src/test/java/com/iheartradio/m3u8/LineParserStateTestCase.java new file mode 100644 index 0000000..3243b94 --- /dev/null +++ b/src/test/java/com/iheartradio/m3u8/LineParserStateTestCase.java @@ -0,0 +1,28 @@ +package com.iheartradio.m3u8; + +import junit.framework.TestCase; + +import org.junit.Test; + +public class LineParserStateTestCase extends TestCase { + protected ParseState mParseState; + + @Override + protected void setUp() throws Exception { + mParseState = new ParseState(Encoding.UTF_8); + } + + protected void assertParseThrows(IExtTagParser handler, String line, ParseExceptionType exceptionType) { + try { + handler.parse(line, mParseState); + assertFalse(true); + } catch (ParseException exception) { + assertEquals(exceptionType, exception.type); + } + } + + @Test + public void test() { + // workaround for no tests found warning + } +} diff --git a/src/test/java/com/iheartradio/m3u8/M3uParserTest.java b/src/test/java/com/iheartradio/m3u8/M3uParserTest.java new file mode 100644 index 0000000..2039fa0 --- /dev/null +++ b/src/test/java/com/iheartradio/m3u8/M3uParserTest.java @@ -0,0 +1,37 @@ +package com.iheartradio.m3u8; + +import com.iheartradio.m3u8.data.MediaPlaylist; +import com.iheartradio.m3u8.data.TrackData; + +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.*; + +public class M3uParserTest { + @Test + public void testParse() throws Exception { + final String absolute = "http://www.my.song/file1.mp3"; + final String relative = "user1/file2.mp3"; + + final String validData = + "#some comment\n" + + absolute + "\n" + + "\n" + + relative + "\n" + + "\n"; + + final List expectedTracks = Arrays.asList( + new TrackData.Builder().withUri(absolute).build(), + new TrackData.Builder().withUri(relative).build()); + + final InputStream inputStream = new ByteArrayInputStream(validData.getBytes("utf-8")); + final MediaPlaylist mediaPlaylist = new M3uParser(inputStream, Encoding.UTF_8).parse().getMediaPlaylist(); + + assertEquals(expectedTracks, mediaPlaylist.getTracks()); + } +} diff --git a/src/test/java/com/iheartradio/m3u8/MasterPlaylistLineParserTest.java b/src/test/java/com/iheartradio/m3u8/MasterPlaylistLineParserTest.java new file mode 100644 index 0000000..da6b12b --- /dev/null +++ b/src/test/java/com/iheartradio/m3u8/MasterPlaylistLineParserTest.java @@ -0,0 +1,93 @@ +package com.iheartradio.m3u8; + +import com.iheartradio.m3u8.data.MediaData; +import com.iheartradio.m3u8.data.MediaType; +import com.iheartradio.m3u8.data.Resolution; +import com.iheartradio.m3u8.data.StreamInfo; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class MasterPlaylistLineParserTest extends LineParserStateTestCase { + @Test + public void testEXT_X_MEDIA() throws Exception { + final List expectedMediaData = new ArrayList(); + final IExtTagParser handler = MasterPlaylistLineParser.EXT_X_MEDIA; + final String tag = Constants.EXT_X_MEDIA_TAG; + final String groupId = "1234"; + final String language = "lang"; + final String associatedLanguage = "assoc-lang"; + final String name = "Foo"; + final String inStreamId = "SERVICE1"; + + expectedMediaData.add(new MediaData.Builder() + .withType(MediaType.CLOSED_CAPTIONS) + .withGroupId(groupId) + .withLanguage(language) + .withAssociatedLanguage(associatedLanguage) + .withName(name) + .withAutoSelect(true) + .withInStreamId(inStreamId) + .withCharacteristics(Arrays.asList("char1", "char2")) + .build()); + + final String line = "#" + tag + + ":TYPE=CLOSED-CAPTIONS" + + ",GROUP-ID=\"" + groupId + "\"" + + ",LANGUAGE=\"" + language + "\"" + + ",ASSOC-LANGUAGE=\"" + associatedLanguage + "\"" + + ",NAME=\"" + name + "\"" + + ",DEFAULT=NO" + + ",AUTOSELECT=YES" + + ",INSTREAM-ID=\"" + inStreamId + "\"" + + ",CHARACTERISTICS=\"char1,char2\""; + + assertEquals(tag, handler.getTag()); + + handler.parse(line, mParseState); + assertEquals(expectedMediaData, mParseState.getMaster().mediaData); + } + + @Test + public void testEXT_X_STREAM_INF() throws Exception { + final IExtTagParser handler = MasterPlaylistLineParser.EXT_X_STREAM_INF; + final String tag = Constants.EXT_X_STREAM_INF_TAG; + final int bandwidth = 10000; + final int averageBandwidth = 5000; + final List codecs = Arrays.asList("h.263", "h.264"); + final Resolution resolution = new Resolution(800, 600); + final String audio = "foo"; + final String video = "bar"; + final String subtitles = "titles"; + final String closedCaptions = "captions"; + + final StreamInfo expectedStreamInfo = new StreamInfo.Builder() + .withBandwidth(bandwidth) + .withAverageBandwidth(averageBandwidth) + .withCodecs(codecs) + .withResolution(resolution) + .withAudio(audio) + .withVideo(video) + .withSubtitles(subtitles) + .withClosedCaptions(closedCaptions) + .build(); + + final String line = "#" + tag + + ":BANDWIDTH=" + bandwidth + + ",AVERAGE-BANDWIDTH=" + averageBandwidth + + ",CODECS=\"" + codecs.get(0) + "," + codecs.get(1) + "\"" + + ",RESOLUTION=" + resolution.width + "x" + resolution.height + + ",AUDIO=\"" + audio + "\"" + + ",VIDEO=\"" + video + "\"" + + ",SUBTITLES=\"" + subtitles + "\"" + + ",CLOSED-CAPTIONS=\"" + closedCaptions + "\""; + + assertEquals(tag, handler.getTag()); + + handler.parse(line, mParseState); + assertEquals(expectedStreamInfo, mParseState.getMaster().streamInfo); + } +} diff --git a/src/test/java/com/iheartradio/m3u8/MasterPlaylistParserTest.java b/src/test/java/com/iheartradio/m3u8/MasterPlaylistParserTest.java new file mode 100644 index 0000000..11195b5 --- /dev/null +++ b/src/test/java/com/iheartradio/m3u8/MasterPlaylistParserTest.java @@ -0,0 +1,24 @@ +package com.iheartradio.m3u8; + +import com.iheartradio.m3u8.data.MasterPlaylist; +import com.iheartradio.m3u8.data.Playlist; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class MasterPlaylistParserTest { + @Test + public void test() throws Exception { + final Playlist playlist = TestUtil.parsePlaylistFromResource("masterPlaylist.m3u8"); + final MasterPlaylist masterPlaylist = playlist.getMasterPlaylist(); + + assertTrue(playlist.hasMasterPlaylist()); + assertFalse(playlist.hasMediaPlaylist()); + + assertTrue(masterPlaylist.hasStartData()); + assertEquals(4.5, masterPlaylist.getStartData().getTimeOffset(), 1e-12); + assertFalse(masterPlaylist.getStartData().isPrecise()); + } +} diff --git a/src/test/java/com/iheartradio/m3u8/MediaPlaylistLineParserTest.java b/src/test/java/com/iheartradio/m3u8/MediaPlaylistLineParserTest.java new file mode 100644 index 0000000..97c4ec5 --- /dev/null +++ b/src/test/java/com/iheartradio/m3u8/MediaPlaylistLineParserTest.java @@ -0,0 +1,140 @@ +package com.iheartradio.m3u8; + +import com.iheartradio.m3u8.data.ByteRange; +import com.iheartradio.m3u8.data.EncryptionData; +import com.iheartradio.m3u8.data.EncryptionMethod; +import com.iheartradio.m3u8.data.MapInfo; +import org.junit.Test; + +import java.util.Arrays; + +public class MediaPlaylistLineParserTest extends LineParserStateTestCase { + @Test + public void testEXT_X_TARGETDURATION() throws Exception { + final IExtTagParser handler = MediaPlaylistLineParser.EXT_X_TARGETDURATION; + final String tag = Constants.EXT_X_TARGETDURATION_TAG; + final String line = "#" + tag + ":60"; + + assertEquals(tag, handler.getTag()); + + handler.parse(line, mParseState); + assertEquals(60, (int) mParseState.getMedia().targetDuration); + + assertParseThrows(handler, line, ParseExceptionType.MULTIPLE_EXT_TAG_INSTANCES); + } + + @Test + public void testEXTINF() throws Exception { + final IExtTagParser handler = MediaPlaylistLineParser.EXTINF; + final String tag = Constants.EXTINF_TAG; + final String line = "#" + tag + ":-1,TOP 100"; + + assertEquals(tag, handler.getTag()); + + handler.parse(line, mParseState); + assertEquals(-1f, mParseState.getMedia().trackInfo.duration); + assertEquals("TOP 100", mParseState.getMedia().trackInfo.title); + } + + @Test + public void testEXT_X_KEY() throws Exception { + final IExtTagParser handler = MediaPlaylistLineParser.EXT_X_KEY; + final String tag = Constants.EXT_X_KEY_TAG; + final String uri = "http://foo.bar.com/"; + final String format = "format"; + + final String line = "#" + tag + + ":METHOD=AES-128" + + ",URI=\"" + uri + "\"" + + ",IV=0x1234abcd5678EF90aabbccddeeff0011" + + ",KEYFORMAT=\"" + format + "\"" + + ",KEYFORMATVERSIONS=\"1/2/3\""; + + assertEquals(tag, handler.getTag()); + + handler.parse(line, mParseState); + EncryptionData encryptionData = mParseState.getMedia().encryptionData; + assertEquals(EncryptionMethod.AES, encryptionData.getMethod()); + assertEquals(uri, encryptionData.getUri()); + + assertEquals( + Arrays.asList((byte) 0x12, (byte) 0x34, (byte) 0xAB, (byte) 0xCD, + (byte) 0x56, (byte) 0x78, (byte) 0xEF, (byte) 0x90, + (byte) 0xAA, (byte) 0xBB, (byte) 0xCC, (byte) 0xDD, + (byte) 0xEE, (byte) 0xFF, (byte) 0x00, (byte) 0x11), + encryptionData.getInitializationVector()); + + assertEquals(format, encryptionData.getKeyFormat()); + assertEquals(Arrays.asList(1, 2, 3), encryptionData.getKeyFormatVersions()); + } + + @Test + public void testEXT_X_MAP() throws Exception { + final IExtTagParser handler = MediaPlaylistLineParser.EXT_X_MAP; + final String tag = Constants.EXT_X_MAP; + final String uri = "init.mp4"; + final long subRangeLength = 350; + final Long offset = 76L; + + final String line = "#" + tag + + ":URI=\"" + uri + "\"" + + ",BYTERANGE=\"" + subRangeLength + "@" + offset + "\""; + + assertEquals(tag, handler.getTag()); + handler.parse(line, mParseState); + MapInfo mapInfo = mParseState.getMedia().mapInfo; + assertEquals(uri, mapInfo.getUri()); + assertNotNull(mapInfo.getByteRange()); + assertEquals(subRangeLength, mapInfo.getByteRange().getSubRangeLength()); + assertEquals(offset, mapInfo.getByteRange().getOffset()); + } + + @Test + public void testEXT_X_BYTERANGE() throws Exception { + final IExtTagParser handler = MediaPlaylistLineParser.EXT_X_BYTERANGE; + final String tag = Constants.EXT_X_BYTERANGE_TAG; + final long subRangeLength = 350; + final Long offset = 70L; + + final String line = "#" + tag + ":" + subRangeLength + "@" + offset; + + assertEquals(tag, handler.getTag()); + handler.parse(line, mParseState); + ByteRange byteRange = mParseState.getMedia().byteRange; + assertEquals(subRangeLength, byteRange.getSubRangeLength()); + assertEquals(offset, byteRange.getOffset()); + } + + @Test + public void testEXT_X_PROGRAM_DATE_TIME_withPositiveOffset() throws Exception { + testProgramDateTime("2021-03-20T17:04:20.694+0245"); + } + + @Test + public void testEXT_X_PROGRAM_DATE_TIME_withNegativeOffset() throws Exception { + testProgramDateTime("2021-03-20T17:04:20.694-0300"); + } + + @Test + public void testEXT_X_PROGRAM_DATE_TIME_withPositiveShortOffset() throws Exception { + testProgramDateTime("2021-03-20T17:04:20.694+02"); + } + + @Test + public void testEXT_X_PROGRAM_DATE_TIME_withPositiveOffsetAndColon() throws Exception { + testProgramDateTime("2021-03-20T17:04:20.694+02:45"); + } + + @Test + public void testEXT_X_PROGRAM_DATE_TIME_withZ() throws Exception { + testProgramDateTime("2021-03-20T17:04:20.694Z"); + } + + private void testProgramDateTime(String dateTime) throws ParseException { + final String tag = Constants.EXT_X_PROGRAM_DATE_TIME_TAG; + IExtTagParser handler = MediaPlaylistLineParser.EXT_X_PROGRAM_DATE_TIME; + String line = "#" + tag + ":" + dateTime; + assertEquals(tag, handler.getTag()); + handler.parse(line, mParseState); + } +} diff --git a/src/test/java/com/iheartradio/m3u8/MediaPlaylistParserTest.java b/src/test/java/com/iheartradio/m3u8/MediaPlaylistParserTest.java new file mode 100644 index 0000000..ba56040 --- /dev/null +++ b/src/test/java/com/iheartradio/m3u8/MediaPlaylistParserTest.java @@ -0,0 +1,24 @@ +package com.iheartradio.m3u8; + +import com.iheartradio.m3u8.data.MediaPlaylist; +import com.iheartradio.m3u8.data.Playlist; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class MediaPlaylistParserTest { + @Test + public void test() throws Exception { + final Playlist playlist = TestUtil.parsePlaylistFromResource("mediaPlaylist.m3u8"); + final MediaPlaylist mediaPlaylist = playlist.getMediaPlaylist(); + + assertFalse(playlist.hasMasterPlaylist()); + assertTrue(playlist.hasMediaPlaylist()); + assertTrue(mediaPlaylist.hasStartData()); + assertEquals(-4.5, mediaPlaylist.getStartData().getTimeOffset(), 1e-12); + assertTrue(mediaPlaylist.getStartData().isPrecise()); + assertEquals(10, mediaPlaylist.getTargetDuration()); + } +} diff --git a/src/test/java/com/iheartradio/m3u8/ParseUtilParseHexadecimalTest.java b/src/test/java/com/iheartradio/m3u8/ParseUtilParseHexadecimalTest.java new file mode 100644 index 0000000..a31f9b0 --- /dev/null +++ b/src/test/java/com/iheartradio/m3u8/ParseUtilParseHexadecimalTest.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2017, Spiideo + */ + +package com.iheartradio.m3u8; + +import java.util.Arrays; +import java.util.List; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +/** + * @author Raniz + * @since 02/08/17. + */ +@RunWith(Parameterized.class) +public class ParseUtilParseHexadecimalTest { + + @Parameterized.Parameters(name = "{index}: {1}") + public static Iterable data() { + return Arrays.asList(new Object[][]{ + {Arrays.asList((byte) 0), "0x00"}, + {Arrays.asList((byte) 1), "0x01"}, + {Arrays.asList((byte) -1), "0xff"}, + {Arrays.asList((byte) -16), "0xf0"}, + {Arrays.asList((byte) 0, (byte) 1), "0x0001"}, + {Arrays.asList((byte) 1, (byte) 1), "0x0101"}, + {Arrays.asList((byte) -1, (byte) -1), "0xffff"}, + {Arrays.asList((byte) -121, (byte) -6), "0x87fa"}, + {Arrays.asList((byte) 75, (byte) 118), "0x4b76"}, + }); + } + + private final List expected; + private final String input; + + public ParseUtilParseHexadecimalTest(final List expected, final String input) { + this.expected = expected; + this.input = input; + } + + @Test + public void parseHexadecimal() throws Exception { + Assert.assertEquals(expected, ParseUtil.parseHexadecimal(input, "")); + } + +} diff --git a/src/test/java/com/iheartradio/m3u8/PlaylistParserWriterTest.java b/src/test/java/com/iheartradio/m3u8/PlaylistParserWriterTest.java new file mode 100644 index 0000000..f59243f --- /dev/null +++ b/src/test/java/com/iheartradio/m3u8/PlaylistParserWriterTest.java @@ -0,0 +1,217 @@ +package com.iheartradio.m3u8; + +import static org.junit.Assert.*; + +import java.io.ByteArrayOutputStream; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.Ignore; +import org.junit.Test; + +import com.iheartradio.m3u8.data.ByteRange; +import com.iheartradio.m3u8.data.IFrameStreamInfo; +import com.iheartradio.m3u8.data.MasterPlaylist; +import com.iheartradio.m3u8.data.MediaPlaylist; +import com.iheartradio.m3u8.data.Playlist; +import com.iheartradio.m3u8.data.PlaylistData; +import com.iheartradio.m3u8.data.TrackData; + +public class PlaylistParserWriterTest { + Playlist readPlaylist(String fileName) throws IOException, ParseException, PlaylistException { + assertNotNull(fileName); + + try(InputStream is = new FileInputStream("src/test/resources/" + fileName)) { + Playlist playlist = new PlaylistParser(is, Format.EXT_M3U, Encoding.UTF_8).parse(); + return playlist; + } + } + + String writePlaylist(Playlist playlist) throws IOException, ParseException, PlaylistException { + assertNotNull(playlist); + + try(ByteArrayOutputStream os = new ByteArrayOutputStream()) { + PlaylistWriter writer = new PlaylistWriter(os, Format.EXT_M3U, Encoding.UTF_8); + writer.write(playlist); + + return os.toString(Encoding.UTF_8.value); + } + } + + @Test + public void simpleMediaPlaylist() throws IOException, ParseException, PlaylistException { + Playlist playlist = readPlaylist("simpleMediaPlaylist.m3u8"); + + String sPlaylist = writePlaylist(playlist); + + System.out.println(sPlaylist); + } + + @Test + public void liveMediaPlaylist() throws IOException, ParseException, PlaylistException { + Playlist playlist = readPlaylist("liveMediaPlaylist.m3u8"); + + String sPlaylist = writePlaylist(playlist); + + System.out.println(sPlaylist); + } + + @Test + public void playlistWithEncryptedMediaSegments() throws IOException, ParseException, PlaylistException { + Playlist playlist = readPlaylist("playlistWithEncryptedMediaSegments.m3u8"); + + String sPlaylist = writePlaylist(playlist); + + System.out.println(sPlaylist); + } + + @Test + public void masterPlaylist() throws IOException, ParseException, PlaylistException { + Playlist playlist = readPlaylist("masterPlaylist.m3u8"); + + String sPlaylist = writePlaylist(playlist); + + System.out.println(sPlaylist); + } + + @Test + public void masterPlaylistWithIFrames() throws IOException, ParseException, PlaylistException { + Playlist playlist = readPlaylist("masterPlaylistWithIFrames.m3u8"); + assertTrue(playlist.hasMasterPlaylist()); + + MasterPlaylist masterPlaylist = playlist.getMasterPlaylist(); + assertNotNull(masterPlaylist); + + List playlistDatas = masterPlaylist.getPlaylists(); + List iFrameInfo = masterPlaylist.getIFramePlaylists(); + assertNotNull(playlistDatas); + assertNotNull(iFrameInfo); + assertEquals(4, playlistDatas.size()); + assertEquals(3, iFrameInfo.size()); + + PlaylistData lowXStreamInf = playlistDatas.get(0); + assertNotNull(lowXStreamInf); + assertNotNull(lowXStreamInf.getStreamInfo()); + assertEquals(1280000, lowXStreamInf.getStreamInfo().getBandwidth()); + assertEquals("low/audio-video.m3u8", lowXStreamInf.getUri()); + + PlaylistData midXStreamInf = playlistDatas.get(1); + assertNotNull(midXStreamInf); + assertNotNull(midXStreamInf.getStreamInfo()); + assertEquals(2560000, midXStreamInf.getStreamInfo().getBandwidth()); + assertEquals("mid/audio-video.m3u8", midXStreamInf.getUri()); + + PlaylistData hiXStreamInf = playlistDatas.get(2); + assertNotNull(hiXStreamInf); + assertNotNull(hiXStreamInf.getStreamInfo()); + assertEquals(7680000, hiXStreamInf.getStreamInfo().getBandwidth()); + assertEquals("hi/audio-video.m3u8", hiXStreamInf.getUri()); + + PlaylistData audioXStreamInf = playlistDatas.get(3); + assertNotNull(audioXStreamInf); + assertNotNull(audioXStreamInf.getStreamInfo()); + assertEquals(65000, audioXStreamInf.getStreamInfo().getBandwidth()); + assertNotNull(audioXStreamInf.getStreamInfo().getCodecs()); + assertEquals(1, audioXStreamInf.getStreamInfo().getCodecs().size()); + assertEquals("mp4a.40.5", audioXStreamInf.getStreamInfo().getCodecs().get(0)); + assertEquals("audio-only.m3u8", audioXStreamInf.getUri()); + + IFrameStreamInfo lowXIFrameStreamInf = iFrameInfo.get(0); + assertNotNull(lowXIFrameStreamInf); + assertEquals(86000, lowXIFrameStreamInf.getBandwidth()); + assertEquals("low/iframe.m3u8", lowXIFrameStreamInf.getUri()); + + IFrameStreamInfo midXIFrameStreamInf = iFrameInfo.get(1); + assertNotNull(midXIFrameStreamInf); + assertEquals(150000, midXIFrameStreamInf.getBandwidth()); + assertEquals("mid/iframe.m3u8", midXIFrameStreamInf.getUri()); + + IFrameStreamInfo hiXIFrameStreamInf = iFrameInfo.get(2); + assertNotNull(hiXIFrameStreamInf); + assertEquals(550000, hiXIFrameStreamInf.getBandwidth()); + assertEquals("hi/iframe.m3u8", hiXIFrameStreamInf.getUri()); + + String writtenPlaylist = writePlaylist(playlist); + assertEquals( + "#EXTM3U\n" + + "#EXT-X-VERSION:1\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=1280000\n" + + "low/audio-video.m3u8\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=2560000\n" + + "mid/audio-video.m3u8\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=7680000\n" + + "hi/audio-video.m3u8\n" + + "#EXT-X-STREAM-INF:CODECS=\"mp4a.40.5\",BANDWIDTH=65000\n" + + "audio-only.m3u8\n" + + "#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=86000,URI=\"low/iframe.m3u8\"\n" + + "#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=150000,URI=\"mid/iframe.m3u8\"\n" + + "#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=550000,URI=\"hi/iframe.m3u8\"\n", + writtenPlaylist); + } + + @Test + public void masterPlaylistWithAlternativeAudio() throws IOException, ParseException, PlaylistException { + Playlist playlist = readPlaylist("masterPlaylistWithAlternativeAudio.m3u8"); + + String sPlaylist = writePlaylist(playlist); + + System.out.println(sPlaylist); + } + + @Test + public void masterPlaylistWithAlternativeVideo() throws IOException, ParseException, PlaylistException { + Playlist playlist = readPlaylist("masterPlaylistWithAlternativeVideo.m3u8"); + + String sPlaylist = writePlaylist(playlist); + + System.out.println(sPlaylist); + } + + @Test + public void discontinutyPlaylist() throws IOException, ParseException, PlaylistException { + Playlist playlist = readPlaylist("withDiscontinuity.m3u8"); + String sPlaylist = writePlaylist(playlist); + System.out.println("***************"); + System.out.println(sPlaylist); + } + + @Test + @Ignore + public void playlistWithByteRanges() throws Exception { + final Playlist playlist = TestUtil.parsePlaylistFromResource("mediaPlaylistWithByteRanges.m3u8"); + final MediaPlaylist mediaPlaylist = playlist.getMediaPlaylist(); + List byteRanges = new ArrayList<>(); + for (TrackData track : mediaPlaylist.getTracks()) { + assertTrue(track.hasByteRange()); + byteRanges.add(track.getByteRange()); + } + + List expected = Arrays.asList( + new ByteRange(0, 10), + new ByteRange(20), + new ByteRange(30) + ); + + assertEquals(expected, byteRanges); + + assertEquals( + "#EXTM3U\n" + + "#EXT-X-VERSION:4\n" + + "#EXT-X-TARGETDURATION:10\n" + + "#EXT-X-MEDIA-SEQUENCE:0\n"+ + "#EXT-X-BYTERANGE:0@10\n" + + "#EXTINF:9.009\n" + + "http://media.example.com/first.ts\n" + + "#EXT-X-BYTERANGE:20\n" + + "#EXTINF:9.009\n" + + "http://media.example.com/first.ts\n" + + "#EXT-X-BYTERANGE:30\n" + + "#EXTINF:3.003\n" + + "http://media.example.com/first.ts\n" + + "#EXT-X-ENDLIST\n", writePlaylist(playlist)); + } +} diff --git a/src/test/java/com/iheartradio/m3u8/PlaylistValidationTest.java b/src/test/java/com/iheartradio/m3u8/PlaylistValidationTest.java new file mode 100644 index 0000000..b62baae --- /dev/null +++ b/src/test/java/com/iheartradio/m3u8/PlaylistValidationTest.java @@ -0,0 +1,46 @@ +package com.iheartradio.m3u8; + +import com.iheartradio.m3u8.data.Playlist; +import org.junit.Test; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static com.iheartradio.m3u8.TestUtil.inputStreamFromResource; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class PlaylistValidationTest { + @Test + public void testAllowNegativeNumbersValidation() throws Exception { + Playlist playlist; + boolean found = false; + + try (final InputStream inputStream = inputStreamFromResource("negativeDurationMediaPlaylist.m3u8")) { + new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8).parse(); + } catch (final PlaylistException exception) { + found = exception.getErrors().contains(PlaylistError.TRACK_INFO_WITH_NEGATIVE_DURATION); + } + + assertTrue(found); + + try (final InputStream inputStream = inputStreamFromResource("negativeDurationMediaPlaylist.m3u8")) { + playlist = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT).parse(); + } + + assertEquals(-1f, playlist.getMediaPlaylist().getTracks().get(0).getTrackInfo().duration, 0f); + } + + @Test + public void testInvalidBytRange() throws Exception { + List errors = new ArrayList<>(); + try { + TestUtil.parsePlaylistFromResource("mediaPlaylistWithInvalidByteRanges.m3u8"); + } catch (PlaylistException e) { + errors.addAll(e.getErrors()); + } + assertEquals(Collections.singletonList(PlaylistError.BYTERANGE_WITH_UNDEFINED_OFFSET), errors); + } +} \ No newline at end of file diff --git a/src/test/java/com/iheartradio/m3u8/TestUtil.java b/src/test/java/com/iheartradio/m3u8/TestUtil.java new file mode 100644 index 0000000..ed23195 --- /dev/null +++ b/src/test/java/com/iheartradio/m3u8/TestUtil.java @@ -0,0 +1,30 @@ +package com.iheartradio.m3u8; + +import com.iheartradio.m3u8.data.Playlist; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; + +import static org.junit.Assert.assertNotNull; + +public class TestUtil { + public static InputStream inputStreamFromResource(final String fileName) { + assertNotNull(fileName); + + try { + return new FileInputStream("src/test/resources/" + fileName); + } catch (FileNotFoundException e) { + throw new RuntimeException("failed to open playlist file: " + fileName); + } + } + + public static Playlist parsePlaylistFromResource(final String fileName) throws IOException, ParseException, PlaylistException { + assertNotNull(fileName); + + try (InputStream is = new FileInputStream("src/test/resources/" + fileName)) { + return new PlaylistParser(is, Format.EXT_M3U, Encoding.UTF_8).parse(); + } + } +} diff --git a/src/test/java/com/iheartradio/m3u8/WriteUtilTest.java b/src/test/java/com/iheartradio/m3u8/WriteUtilTest.java new file mode 100644 index 0000000..8e96eb1 --- /dev/null +++ b/src/test/java/com/iheartradio/m3u8/WriteUtilTest.java @@ -0,0 +1,27 @@ +package com.iheartradio.m3u8; + +import org.junit.Test; + +import static org.hamcrest.core.Is.is; +import static org.junit.Assert.*; + +public class WriteUtilTest { + + @Test + public void writeQuotedStringShouldIgnoreNullTagValueForOptionalFields() throws Exception { + String outputString =WriteUtil.writeQuotedString(null,"some-key", true); + + assertThat(outputString,is("\"\"")); + } + + @Test(expected = NullPointerException.class) + public void writeQuotedStringShouldNotIgnoreNullTagValue() throws Exception { + WriteUtil.writeQuotedString(null,"some-key"); + } + + @Test + public void writeQuotedStringShouldNotIgnoreSuppliedOptionalValue() throws Exception { + assertThat(WriteUtil.writeQuotedString("blah","some-key"),is("\"blah\"")); + } + +} \ No newline at end of file diff --git a/src/test/java/com/iheartradio/m3u8/WriteUtilWriteHexadecimalTest.java b/src/test/java/com/iheartradio/m3u8/WriteUtilWriteHexadecimalTest.java new file mode 100644 index 0000000..b8cc66b --- /dev/null +++ b/src/test/java/com/iheartradio/m3u8/WriteUtilWriteHexadecimalTest.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2017, Spiideo + */ + +package com.iheartradio.m3u8; + +import java.util.Arrays; +import java.util.List; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +/** + * @author Raniz + * @since 02/08/17. + */ +@RunWith(Parameterized.class) +public class WriteUtilWriteHexadecimalTest { + + @Parameterized.Parameters(name = "{index}: {1}") + public static Iterable data() { + return Arrays.asList(new Object[][]{ + {Arrays.asList((byte) 0), "0x00"}, + {Arrays.asList((byte) 1), "0x01"}, + {Arrays.asList((byte) -1), "0xff"}, + {Arrays.asList((byte) -16), "0xf0"}, + {Arrays.asList((byte) 0, (byte) 1), "0x0001"}, + {Arrays.asList((byte) 1, (byte) 1), "0x0101"}, + {Arrays.asList((byte) -1, (byte) -1), "0xffff"}, + {Arrays.asList((byte) -121, (byte) -6), "0x87fa"}, + {Arrays.asList((byte) 75, (byte) 118), "0x4b76"}, + }); + } + + private final List input; + private final String expected; + + public WriteUtilWriteHexadecimalTest(final List input, final String expected) { + this.input = input; + this.expected = expected; + } + + @Test + public void writeHexadecimal() throws Exception { + Assert.assertEquals(expected, WriteUtil.writeHexadecimal(input)); + } + +} \ No newline at end of file diff --git a/src/test/resources/liveMediaPlaylist.m3u8 b/src/test/resources/liveMediaPlaylist.m3u8 new file mode 100644 index 0000000..bb2bdc5 --- /dev/null +++ b/src/test/resources/liveMediaPlaylist.m3u8 @@ -0,0 +1,11 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:8 +#EXT-X-MEDIA-SEQUENCE:2680 + +#EXTINF:7.975, +https://priv.example.com/fileSequence2680.ts +#EXTINF:7.941, +https://priv.example.com/fileSequence2681.ts +#EXTINF:7.975, +https://priv.example.com/fileSequence2682.ts diff --git a/src/test/resources/masterPlaylist.m3u8 b/src/test/resources/masterPlaylist.m3u8 new file mode 100644 index 0000000..85f8921 --- /dev/null +++ b/src/test/resources/masterPlaylist.m3u8 @@ -0,0 +1,10 @@ +#EXTM3U +#EXT-X-START:TIME-OFFSET=4.5,PRECISE=NO +#EXT-X-STREAM-INF:BANDWIDTH=1280000,AVERAGE-BANDWIDTH=1000000 +http://example.com/low.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=2560000,AVERAGE-BANDWIDTH=2000000 +http://example.com/mid.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=7680000,AVERAGE-BANDWIDTH=6000000 +http://example.com/hi.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS="mp4a.40.5" +http://example.com/audio-only.m3u8 diff --git a/src/test/resources/masterPlaylistWithAlternativeAudio.m3u8 b/src/test/resources/masterPlaylistWithAlternativeAudio.m3u8 new file mode 100644 index 0000000..4db7352 --- /dev/null +++ b/src/test/resources/masterPlaylistWithAlternativeAudio.m3u8 @@ -0,0 +1,12 @@ +#EXTM3U +#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",NAME="English",DEFAULT=YES,AUTOSELECT=YES,LANGUAGE="en",CHANNELS="2",URI="main/english-audio.m3u8" +#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",NAME="Deutsch",DEFAULT=NO,AUTOSELECT=YES,LANGUAGE="de",CHANNELS="2",URI="main/german-audio.m3u8" +#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",NAME="Commentary",DEFAULT=NO,AUTOSELECT=NO,LANGUAGE="en",CHANNELS="2",URI="commentary/audio-only.m3u8" +#EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS="...",AUDIO="aac" +low/video-only.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=2560000,CODECS="...",AUDIO="aac" +mid/video-only.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=7680000,CODECS="...",AUDIO="aac" +hi/video-only.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS="mp4a.40.5",AUDIO="aac" +main/english-audio.m3u8 diff --git a/src/test/resources/masterPlaylistWithAlternativeVideo.m3u8 b/src/test/resources/masterPlaylistWithAlternativeVideo.m3u8 new file mode 100644 index 0000000..49ee0cb --- /dev/null +++ b/src/test/resources/masterPlaylistWithAlternativeVideo.m3u8 @@ -0,0 +1,24 @@ +#EXTM3U +#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="low",NAME="Main",DEFAULT=YES,URI="low/main/audio-video.m3u8" +#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="low",NAME="Centerfield",DEFAULT=NO,URI="low/centerfield/audio-video.m3u8" +#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="low",NAME="Dugout",DEFAULT=NO,URI="low/dugout/audio-video.m3u8" + +#EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS="...",VIDEO="low" +low/main/audio-video.m3u8 + +#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="mid",NAME="Main",DEFAULT=YES,URI="mid/main/audio-video.m3u8" +#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="mid",NAME="Centerfield",DEFAULT=NO,URI="mid/centerfield/audio-video.m3u8" +#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="mid",NAME="Dugout",DEFAULT=NO,URI="mid/dugout/audio-video.m3u8" + +#EXT-X-STREAM-INF:BANDWIDTH=2560000,CODECS="...",VIDEO="mid" +mid/main/audio-video.m3u8 + +#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="hi",NAME="Main",DEFAULT=YES,URI="hi/main/audio-video.m3u8" +#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="hi",NAME="Centerfield",DEFAULT=NO,URI="hi/centerfield/audio-video.m3u8" +#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="hi",NAME="Dugout",DEFAULT=NO,URI="hi/dugout/audio-video.m3u8" + +#EXT-X-STREAM-INF:BANDWIDTH=7680000,CODECS="...",VIDEO="hi" +hi/main/audio-video.m3u8 + +#EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS="mp4a.40.5" +main/audio-only.m3u8 diff --git a/src/test/resources/masterPlaylistWithIFrames.m3u8 b/src/test/resources/masterPlaylistWithIFrames.m3u8 new file mode 100644 index 0000000..0ed5549 --- /dev/null +++ b/src/test/resources/masterPlaylistWithIFrames.m3u8 @@ -0,0 +1,12 @@ +#EXTM3U +#EXT-X-STREAM-INF:BANDWIDTH=1280000 +low/audio-video.m3u8 +#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=86000,URI="low/iframe.m3u8" +#EXT-X-STREAM-INF:BANDWIDTH=2560000 +mid/audio-video.m3u8 +#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=150000,URI="mid/iframe.m3u8" +#EXT-X-STREAM-INF:BANDWIDTH=7680000 +hi/audio-video.m3u8 +#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=550000,URI="hi/iframe.m3u8" +#EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS="mp4a.40.5" +audio-only.m3u8 diff --git a/src/test/resources/mediaPlaylist.m3u8 b/src/test/resources/mediaPlaylist.m3u8 new file mode 100644 index 0000000..4ef13de --- /dev/null +++ b/src/test/resources/mediaPlaylist.m3u8 @@ -0,0 +1,11 @@ +#EXTM3U +#EXT-X-VERSION:4 +#EXT-X-START:TIME-OFFSET=-4.5,PRECISE=YES +#EXT-X-TARGETDURATION:10 +#EXTINF:9.009, +http://media.example.com/first.ts +#EXTINF:9.009, +http://media.example.com/second.ts +#EXTINF:3.003, +http://media.example.com/third.ts +#EXT-X-ENDLIST diff --git a/src/test/resources/mediaPlaylistWithByteRanges.m3u8 b/src/test/resources/mediaPlaylistWithByteRanges.m3u8 new file mode 100644 index 0000000..1af3597 --- /dev/null +++ b/src/test/resources/mediaPlaylistWithByteRanges.m3u8 @@ -0,0 +1,13 @@ +#EXTM3U +#EXT-X-VERSION:4 +#EXT-X-TARGETDURATION:10 +#EXT-X-BYTERANGE:0@10 +#EXTINF:9.009, +http://media.example.com/first.ts +#EXT-X-BYTERANGE:20 +#EXTINF:9.009, +http://media.example.com/first.ts +#EXT-X-BYTERANGE:30 +#EXTINF:3.003, +http://media.example.com/first.ts +#EXT-X-ENDLIST diff --git a/src/test/resources/mediaPlaylistWithInvalidByteRanges.m3u8 b/src/test/resources/mediaPlaylistWithInvalidByteRanges.m3u8 new file mode 100644 index 0000000..d3e4ad4 --- /dev/null +++ b/src/test/resources/mediaPlaylistWithInvalidByteRanges.m3u8 @@ -0,0 +1,13 @@ +#EXTM3U +#EXT-X-VERSION:4 +#EXT-X-TARGETDURATION:10 +#EXT-X-BYTERANGE:0@10 +#EXTINF:9.009, +http://media.example.com/first.ts +#EXT-X-BYTERANGE:20 +#EXTINF:9.009, +http://media.example.com/second.ts +#EXT-X-BYTERANGE:30 +#EXTINF:3.003, +http://media.example.com/first.ts +#EXT-X-ENDLIST diff --git a/src/test/resources/negativeDurationMediaPlaylist.m3u8 b/src/test/resources/negativeDurationMediaPlaylist.m3u8 new file mode 100644 index 0000000..9a68410 --- /dev/null +++ b/src/test/resources/negativeDurationMediaPlaylist.m3u8 @@ -0,0 +1,7 @@ +#EXTM3U +#EXTINF:-1,TOP 100 +http://radio.promodj.com:8000/top100-192 +#EXTINF:-1,Channel 5 +http://radio.promodj.com:8000/channel5-192 +#EXTINF:-1,Klubb +http://radio.promodj.com:8000/klubb-192 \ No newline at end of file diff --git a/src/test/resources/playlistWithEncryptedMediaSegments.m3u8 b/src/test/resources/playlistWithEncryptedMediaSegments.m3u8 new file mode 100644 index 0000000..4bec6bd --- /dev/null +++ b/src/test/resources/playlistWithEncryptedMediaSegments.m3u8 @@ -0,0 +1,18 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:7794 +#EXT-X-TARGETDURATION:15 + +#EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52" + +#EXTINF:2.833, +http://media.example.com/fileSequence52-A.ts +#EXTINF:15.0, +http://media.example.com/fileSequence52-B.ts +#EXTINF:13.333, +http://media.example.com/fileSequence52-C.ts + +#EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=53" + +#EXTINF:15.0, +http://media.example.com/fileSequence53-A.ts diff --git a/src/test/resources/simpleMediaPlaylist.m3u8 b/src/test/resources/simpleMediaPlaylist.m3u8 new file mode 100644 index 0000000..1fea0f8 --- /dev/null +++ b/src/test/resources/simpleMediaPlaylist.m3u8 @@ -0,0 +1,10 @@ +#EXTM3U +#EXT-X-VERSION:4 +#EXT-X-TARGETDURATION:10 +#EXTINF:9.009, +http://media.example.com/first.ts +#EXTINF:9.009, +http://media.example.com/second.ts +#EXTINF:3.003, +http://media.example.com/third.ts +#EXT-X-ENDLIST diff --git a/src/test/resources/twoMediaPlaylists.m3u8 b/src/test/resources/twoMediaPlaylists.m3u8 new file mode 100644 index 0000000..f814721 --- /dev/null +++ b/src/test/resources/twoMediaPlaylists.m3u8 @@ -0,0 +1,15 @@ +#EXTM3U +#EXT-X-TARGETDURATION:10 +#EXTINF:9.009 +http://media.example.com/first.ts +#EXTINF:9.009 +http://media.example.com/second.ts +#EXTINF:3.003 +http://media.example.com/third.ts +#EXT-X-ENDLIST +#EXTM3U +#EXT-X-TARGETDURATION:10 +#EXTINF:9.010 +http://media.example.com/fourth.ts +#EXTINF:9.011 +http://media.example.com/fifth.ts diff --git a/src/test/resources/withDiscontinuity.m3u8 b/src/test/resources/withDiscontinuity.m3u8 new file mode 100644 index 0000000..279a1d1 --- /dev/null +++ b/src/test/resources/withDiscontinuity.m3u8 @@ -0,0 +1,16 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:10 +#EXT-X-MEDIA-SEQUENCE:0 +#EXTINF:10,Advertisement +http://myserver/api/data/params?pubid=e4ac7b50-0422-4938-be6a-b0f49c50ebfc&chname=sample12.mp4&profid=1&slotid=1&pcrpts=0&ssid=<1234567890123456>&breakid=1&contenturl= +#EXTINF:10,Advertisement +http://myserver/api/data/params?pubid=e4ac7b50-0422-4938-be6a-b0f49c50ebfc&chname=sample12.mp4&profid=1&slotid=2&pcrpts=0&ssid=<1234567890123456>&breakid=1&contenturl= +#EXTINF:10,Advertisement +http://myserver/api/data/params?pubid=e4ac7b50-0422-4938-be6a-b0f49c50ebfc&chname=sample12.mp4&profid=1&slotid=3&pcrpts=0&ssid=<1234567890123456>&breakid=1&contenturl= +#EXT-X-DISCONTINUITY +#EXTINF:10 +http://contentserver/vod/sample.mp4/media_w1013470664_0.ts +#EXTINF:10 +http://contentserver/vod/sample.mp4/media_w1013470664_1.ts +#EXT-X-ENDLIST \ No newline at end of file