From 46dc48f26f614776a40aa8e6baeef01fe54a2461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20Lindstr=C3=B6m?= Date: Tue, 22 Aug 2017 18:32:31 +0200 Subject: [PATCH 1/3] Add support for EXT-X-MAP --- .../java/com/iheartradio/m3u8/Constants.java | 8 +- .../iheartradio/m3u8/ExtendedM3uParser.java | 7 +- .../com/iheartradio/m3u8/MediaParseState.java | 10 +-- .../m3u8/MediaPlaylistLineParser.java | 58 +++++++++++++-- .../m3u8/MediaPlaylistTagWriter.java | 74 +++++++++++++++++-- .../iheartradio/m3u8/ParseExceptionType.java | 1 + .../com/iheartradio/m3u8/PlaylistError.java | 5 ++ .../iheartradio/m3u8/PlaylistValidation.java | 6 ++ .../com/iheartradio/m3u8/TrackLineParser.java | 2 + .../com/iheartradio/m3u8/data/ByteRange.java | 43 +++++++++++ .../com/iheartradio/m3u8/data/MapInfo.java | 74 +++++++++++++++++++ .../com/iheartradio/m3u8/data/TrackData.java | 21 +++++- .../m3u8/MediaPlaylistLineParserTest.java | 23 +++++- 13 files changed, 304 insertions(+), 28 deletions(-) create mode 100644 src/main/java/com/iheartradio/m3u8/data/ByteRange.java create mode 100644 src/main/java/com/iheartradio/m3u8/data/MapInfo.java diff --git a/src/main/java/com/iheartradio/m3u8/Constants.java b/src/main/java/com/iheartradio/m3u8/Constants.java index a1696da..3d1bb09 100644 --- a/src/main/java/com/iheartradio/m3u8/Constants.java +++ b/src/main/java/com/iheartradio/m3u8/Constants.java @@ -27,7 +27,8 @@ final class Constants { // 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"; @@ -78,6 +79,7 @@ final class Constants { 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"; // regular expressions public static final String YES = "YES"; @@ -99,7 +101,9 @@ final class Constants { 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_VALUE_PATTERN = Pattern.compile("^(" + INTEGER_REGEX + ")(?:@(" + INTEGER_REGEX + "))?$"); + // other public static final int[] UTF_8_BOM_BYTES = {0xEF, 0xBB, 0xBF}; diff --git a/src/main/java/com/iheartradio/m3u8/ExtendedM3uParser.java b/src/main/java/com/iheartradio/m3u8/ExtendedM3uParser.java index 72f3cca..daa17c7 100644 --- a/src/main/java/com/iheartradio/m3u8/ExtendedM3uParser.java +++ b/src/main/java/com/iheartradio/m3u8/ExtendedM3uParser.java @@ -1,12 +1,12 @@ 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; -import com.iheartradio.m3u8.data.Playlist; - class ExtendedM3uParser extends BaseM3uParser { private final ParsingMode mParsingMode; private final Map mExtTagParsers = new HashMap(); @@ -32,7 +32,8 @@ class ExtendedM3uParser extends BaseM3uParser { MasterPlaylistLineParser.EXT_X_I_FRAME_STREAM_INF, MediaPlaylistLineParser.EXTINF, MediaPlaylistLineParser.EXT_X_ENDLIST, - MediaPlaylistLineParser.EXT_X_DISCONTINUITY + MediaPlaylistLineParser.EXT_X_DISCONTINUITY, + MediaPlaylistLineParser.EXT_X_MAP ); } diff --git a/src/main/java/com/iheartradio/m3u8/MediaParseState.java b/src/main/java/com/iheartradio/m3u8/MediaParseState.java index 04e92fc..f2f31c0 100644 --- a/src/main/java/com/iheartradio/m3u8/MediaParseState.java +++ b/src/main/java/com/iheartradio/m3u8/MediaParseState.java @@ -1,15 +1,10 @@ package com.iheartradio.m3u8; +import com.iheartradio.m3u8.data.*; + import java.util.ArrayList; import java.util.List; -import com.iheartradio.m3u8.data.EncryptionData; -import com.iheartradio.m3u8.data.MediaPlaylist; -import com.iheartradio.m3u8.data.PlaylistType; -import com.iheartradio.m3u8.data.StartData; -import com.iheartradio.m3u8.data.TrackData; -import com.iheartradio.m3u8.data.TrackInfo; - class MediaParseState implements PlaylistParseState { private List mUnknownTags; private StartData mStartData; @@ -25,6 +20,7 @@ class MediaParseState implements PlaylistParseState { public String programDateTime; public boolean endOfList; public boolean hasDiscontinuity; + public MapInfo mapInfo; @Override public PlaylistParseState setUnknownTags(final List unknownTags) { diff --git a/src/main/java/com/iheartradio/m3u8/MediaPlaylistLineParser.java b/src/main/java/com/iheartradio/m3u8/MediaPlaylistLineParser.java index 7429f8c..a612c57 100644 --- a/src/main/java/com/iheartradio/m3u8/MediaPlaylistLineParser.java +++ b/src/main/java/com/iheartradio/m3u8/MediaPlaylistLineParser.java @@ -1,18 +1,14 @@ 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; -import com.iheartradio.m3u8.data.EncryptionData; -import com.iheartradio.m3u8.data.EncryptionData.Builder; -import com.iheartradio.m3u8.data.EncryptionMethod; -import com.iheartradio.m3u8.data.PlaylistType; -import com.iheartradio.m3u8.data.StartData; -import com.iheartradio.m3u8.data.TrackInfo; - class MediaPlaylistLineParser implements LineParser { private final IExtTagParser tagParser; private final LineParser lineParser; @@ -400,4 +396,52 @@ public void parse(String line, ParseState state) throws ParseException { 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()); + } + + int subRangeLength = Integer.parseInt(matcher.group(1)); + int offset = matcher.group(2) != null ? Integer.parseInt(matcher.group(2)) : 0; + builder.withByteRange(new ByteRange(subRangeLength, offset)); + } + }); + } + + @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(); + } + }; } diff --git a/src/main/java/com/iheartradio/m3u8/MediaPlaylistTagWriter.java b/src/main/java/com/iheartradio/m3u8/MediaPlaylistTagWriter.java index b2bfd86..92fe836 100644 --- a/src/main/java/com/iheartradio/m3u8/MediaPlaylistTagWriter.java +++ b/src/main/java/com/iheartradio/m3u8/MediaPlaylistTagWriter.java @@ -1,15 +1,12 @@ 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; -import com.iheartradio.m3u8.data.EncryptionData; -import com.iheartradio.m3u8.data.MediaPlaylist; -import com.iheartradio.m3u8.data.Playlist; -import com.iheartradio.m3u8.data.StartData; -import com.iheartradio.m3u8.data.TrackData; - abstract class MediaPlaylistTagWriter extends ExtTagWriter { @Override @@ -193,6 +190,7 @@ public void doWrite(TagWriter tagWriter, Playlist playlist, MediaPlaylist mediaP 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()) { @@ -200,6 +198,7 @@ public void write(TagWriter tagWriter, Playlist playlist) throws IOException, Pa } keyWriter.writeTrackData(tagWriter, playlist, trackData); + mapInfoWriter.writeTrackData(tagWriter, playlist, trackData); writeExtinf(tagWriter, playlist, trackData); tagWriter.writeLine(trackData.getUri()); } @@ -318,4 +317,67 @@ void writeTrackData(TagWriter tagWriter, Playlist playlist, TrackData trackData) } } } + + 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(); + if (byteRange.getOffset() > 0) { + return WriteUtil.writeQuotedString( + byteRange.getSubRangeLength() + "@" + byteRange.getOffset(), getTag()); + } else { + return WriteUtil.writeQuotedString(String.valueOf(byteRange.getSubRangeLength()), getTag()); + } + } + + @Override + public boolean containsAttribute(MapInfo attributes) { + return attributes.getByteRange() != null; + } + }); + } + + @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/ParseExceptionType.java b/src/main/java/com/iheartradio/m3u8/ParseExceptionType.java index e3d47d4..ce8d60d 100644 --- a/src/main/java/com/iheartradio/m3u8/ParseExceptionType.java +++ b/src/main/java/com/iheartradio/m3u8/ParseExceptionType.java @@ -19,6 +19,7 @@ public enum ParseExceptionType { 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"), diff --git a/src/main/java/com/iheartradio/m3u8/PlaylistError.java b/src/main/java/com/iheartradio/m3u8/PlaylistError.java index ce59710..1700660 100644 --- a/src/main/java/com/iheartradio/m3u8/PlaylistError.java +++ b/src/main/java/com/iheartradio/m3u8/PlaylistError.java @@ -118,4 +118,9 @@ public enum PlaylistError { * 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, } diff --git a/src/main/java/com/iheartradio/m3u8/PlaylistValidation.java b/src/main/java/com/iheartradio/m3u8/PlaylistValidation.java index 78a7980..ea039ae 100644 --- a/src/main/java/com/iheartradio/m3u8/PlaylistValidation.java +++ b/src/main/java/com/iheartradio/m3u8/PlaylistValidation.java @@ -196,5 +196,11 @@ private static void addTrackDataErrors(TrackData trackData, Set e 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/TrackLineParser.java b/src/main/java/com/iheartradio/m3u8/TrackLineParser.java index cbf366e..daa1fa1 100644 --- a/src/main/java/com/iheartradio/m3u8/TrackLineParser.java +++ b/src/main/java/com/iheartradio/m3u8/TrackLineParser.java @@ -18,10 +18,12 @@ public void parse(String line, ParseState state) throws ParseException { .withEncryptionData(mediaState.encryptionData) .withProgramDateTime(mediaState.programDateTime) .withDiscontinuity(mediaState.hasDiscontinuity) + .withMapInfo(mediaState.mapInfo) .build()); mediaState.trackInfo = null; mediaState.programDateTime = null; mediaState.hasDiscontinuity = false; + mediaState.mapInfo = null; } } 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..8696f19 --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/data/ByteRange.java @@ -0,0 +1,43 @@ +package com.iheartradio.m3u8.data; + +import java.util.Objects; + +public class ByteRange { + private final int subRangeLength; + private final int offset; + + public ByteRange(int subRangeLength, int offset) { + this.subRangeLength = subRangeLength; + this.offset = offset; + } + + public int getSubRangeLength() { + return subRangeLength; + } + + public int getOffset() { + return offset; + } + + @Override + public String toString() { + return "ByteRange{" + + "subRangeLength=" + subRangeLength + + ", offset=" + offset + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ByteRange byteRange = (ByteRange) o; + return subRangeLength == byteRange.subRangeLength && + offset == byteRange.offset; + } + + @Override + public int hashCode() { + return Objects.hash(subRangeLength, offset); + } +} 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..5e636ee --- /dev/null +++ b/src/main/java/com/iheartradio/m3u8/data/MapInfo.java @@ -0,0 +1,74 @@ +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 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/TrackData.java b/src/main/java/com/iheartradio/m3u8/data/TrackData.java index be0b2e1..4abeccf 100644 --- a/src/main/java/com/iheartradio/m3u8/data/TrackData.java +++ b/src/main/java/com/iheartradio/m3u8/data/TrackData.java @@ -8,13 +8,15 @@ public class TrackData { private final EncryptionData mEncryptionData; private final String mProgramDateTime; private final boolean mHasDiscontinuity; + private final MapInfo mMapInfo; - private TrackData(String uri, TrackInfo trackInfo, EncryptionData encryptionData, String programDateTime, boolean hasDiscontinuity) { + private TrackData(String uri, TrackInfo trackInfo, EncryptionData encryptionData, String programDateTime, boolean hasDiscontinuity, MapInfo mapInfo) { mUri = uri; mTrackInfo = trackInfo; mEncryptionData = encryptionData; mProgramDateTime = programDateTime; mHasDiscontinuity = hasDiscontinuity; + mMapInfo = mapInfo; } public String getUri() { @@ -55,6 +57,11 @@ public EncryptionData getEncryptionData() { return mEncryptionData; } + + public boolean hasMapInfo() { + return mMapInfo != null; + } + public Builder buildUpon() { return new Builder(getUri(), mTrackInfo, mEncryptionData, mHasDiscontinuity); } @@ -92,12 +99,17 @@ public String toString() { .toString(); } + public MapInfo getMapInfo() { + return mMapInfo; + } + public static class Builder { private String mUri; private TrackInfo mTrackInfo; private EncryptionData mEncryptionData; private String mProgramDateTime; private boolean mHasDiscontinuity; + private MapInfo mMapInfo; public Builder() { } @@ -134,8 +146,13 @@ public Builder withDiscontinuity(boolean hasDiscontinuity) { return this; } + public Builder withMapInfo(MapInfo mapInfo) { + mMapInfo = mapInfo; + return this; + } + public TrackData build() { - return new TrackData(mUri, mTrackInfo, mEncryptionData, mProgramDateTime, mHasDiscontinuity); + return new TrackData(mUri, mTrackInfo, mEncryptionData, mProgramDateTime, mHasDiscontinuity, mMapInfo); } } } diff --git a/src/test/java/com/iheartradio/m3u8/MediaPlaylistLineParserTest.java b/src/test/java/com/iheartradio/m3u8/MediaPlaylistLineParserTest.java index 3896c48..8788a3a 100644 --- a/src/test/java/com/iheartradio/m3u8/MediaPlaylistLineParserTest.java +++ b/src/test/java/com/iheartradio/m3u8/MediaPlaylistLineParserTest.java @@ -2,7 +2,7 @@ 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; @@ -64,4 +64,25 @@ public void testEXT_X_KEY() throws Exception { 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 int subRangeLength = 350; + final int offset = 76; + + 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()); + } } From c273d5c6c2ad21a1ede0f6a92830338dd70da4da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20Lindstr=C3=B6m?= Date: Tue, 22 Aug 2017 18:49:12 +0200 Subject: [PATCH 2/3] Supress deprecation warnings in ByteOrderMarkTest --- src/test/java/com/iheartradio/m3u8/ByteOrderMarkTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/java/com/iheartradio/m3u8/ByteOrderMarkTest.java b/src/test/java/com/iheartradio/m3u8/ByteOrderMarkTest.java index 3de75b8..e75b702 100644 --- a/src/test/java/com/iheartradio/m3u8/ByteOrderMarkTest.java +++ b/src/test/java/com/iheartradio/m3u8/ByteOrderMarkTest.java @@ -22,6 +22,7 @@ public void testParsingByteOrderMark() throws Exception { } } + @SuppressWarnings("deprecation") @Test public void testWritingByteOrderMark() throws Exception { final Playlist playlist1; From 3be431cb4b35413fca1c7376fa3cf2be6434b5ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20Lindstr=C3=B6m?= Date: Wed, 23 Aug 2017 22:28:37 +0200 Subject: [PATCH 3/3] Add support for EXT-X-BYTERANGE --- .../java/com/iheartradio/m3u8/Constants.java | 6 +- .../iheartradio/m3u8/ExtendedM3uParser.java | 3 +- .../com/iheartradio/m3u8/MediaParseState.java | 1 + .../m3u8/MediaPlaylistLineParser.java | 25 +++++- .../m3u8/MediaPlaylistTagWriter.java | 33 ++++++-- .../java/com/iheartradio/m3u8/ParseUtil.java | 18 +++-- .../com/iheartradio/m3u8/PlaylistError.java | 6 ++ .../iheartradio/m3u8/PlaylistValidation.java | 18 +++++ .../com/iheartradio/m3u8/TrackLineParser.java | 2 + .../com/iheartradio/m3u8/data/ByteRange.java | 41 ++++++---- .../com/iheartradio/m3u8/data/MapInfo.java | 4 + .../com/iheartradio/m3u8/data/TrackData.java | 80 +++++++++++-------- .../m3u8/MediaPlaylistLineParserTest.java | 21 ++++- .../m3u8/PlaylistParserWriterTest.java | 48 +++++++++-- .../m3u8/PlaylistValidationTest.java | 14 ++++ .../mediaPlaylistWithByteRanges.m3u8 | 13 +++ .../mediaPlaylistWithInvalidByteRanges.m3u8 | 13 +++ 17 files changed, 270 insertions(+), 76 deletions(-) create mode 100644 src/test/resources/mediaPlaylistWithByteRanges.m3u8 create mode 100644 src/test/resources/mediaPlaylistWithInvalidByteRanges.m3u8 diff --git a/src/main/java/com/iheartradio/m3u8/Constants.java b/src/main/java/com/iheartradio/m3u8/Constants.java index 3d1bb09..ea2910f 100644 --- a/src/main/java/com/iheartradio/m3u8/Constants.java +++ b/src/main/java/com/iheartradio/m3u8/Constants.java @@ -80,6 +80,7 @@ final class Constants { 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"; @@ -87,6 +88,7 @@ final class Constants { 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 + ")$"); @@ -101,8 +103,8 @@ final class Constants { 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_VALUE_PATTERN = Pattern.compile("^(" + INTEGER_REGEX + ")(?:@(" + INTEGER_REGEX + "))?$"); + 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 diff --git a/src/main/java/com/iheartradio/m3u8/ExtendedM3uParser.java b/src/main/java/com/iheartradio/m3u8/ExtendedM3uParser.java index daa17c7..f27e8a0 100644 --- a/src/main/java/com/iheartradio/m3u8/ExtendedM3uParser.java +++ b/src/main/java/com/iheartradio/m3u8/ExtendedM3uParser.java @@ -33,7 +33,8 @@ class ExtendedM3uParser extends BaseM3uParser { MediaPlaylistLineParser.EXTINF, MediaPlaylistLineParser.EXT_X_ENDLIST, MediaPlaylistLineParser.EXT_X_DISCONTINUITY, - MediaPlaylistLineParser.EXT_X_MAP + MediaPlaylistLineParser.EXT_X_MAP, + MediaPlaylistLineParser.EXT_X_BYTERANGE ); } diff --git a/src/main/java/com/iheartradio/m3u8/MediaParseState.java b/src/main/java/com/iheartradio/m3u8/MediaParseState.java index f2f31c0..507d106 100644 --- a/src/main/java/com/iheartradio/m3u8/MediaParseState.java +++ b/src/main/java/com/iheartradio/m3u8/MediaParseState.java @@ -21,6 +21,7 @@ class MediaParseState implements PlaylistParseState { public boolean endOfList; public boolean hasDiscontinuity; public MapInfo mapInfo; + public ByteRange byteRange; @Override public PlaylistParseState setUnknownTags(final List unknownTags) { diff --git a/src/main/java/com/iheartradio/m3u8/MediaPlaylistLineParser.java b/src/main/java/com/iheartradio/m3u8/MediaPlaylistLineParser.java index a612c57..5dbaf5f 100644 --- a/src/main/java/com/iheartradio/m3u8/MediaPlaylistLineParser.java +++ b/src/main/java/com/iheartradio/m3u8/MediaPlaylistLineParser.java @@ -417,9 +417,7 @@ public void parse(Attribute attribute, MapInfo.Builder builder, ParseState state throw ParseException.create(ParseExceptionType.INVALID_BYTERANGE_FORMAT, getTag(), attribute.toString()); } - int subRangeLength = Integer.parseInt(matcher.group(1)); - int offset = matcher.group(2) != null ? Integer.parseInt(matcher.group(2)) : 0; - builder.withByteRange(new ByteRange(subRangeLength, offset)); + builder.withByteRange(ParseUtil.matchByteRange(matcher)); } }); } @@ -444,4 +442,25 @@ public void parse(String line, ParseState state) throws ParseException { 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 index 92fe836..bfa3ff1 100644 --- a/src/main/java/com/iheartradio/m3u8/MediaPlaylistTagWriter.java +++ b/src/main/java/com/iheartradio/m3u8/MediaPlaylistTagWriter.java @@ -199,6 +199,11 @@ public void write(TagWriter tagWriter, Playlist playlist) throws IOException, Pa 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()); } @@ -222,6 +227,19 @@ private static void writeExtinf(TagWriter tagWriter, Playlist playlist, TrackDat 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>(); @@ -340,17 +358,20 @@ public boolean containsAttribute(MapInfo attributes) { @Override public String write(MapInfo attributes) throws ParseException { ByteRange byteRange = attributes.getByteRange(); - if (byteRange.getOffset() > 0) { - return WriteUtil.writeQuotedString( - byteRange.getSubRangeLength() + "@" + byteRange.getOffset(), getTag()); + String value; + if (byteRange.hasOffset()) { + value = String.valueOf(byteRange.getSubRangeLength()) + + '@' + String.valueOf(byteRange.getOffset()); } else { - return WriteUtil.writeQuotedString(String.valueOf(byteRange.getSubRangeLength()), getTag()); + value = String.valueOf(byteRange.getSubRangeLength()); } + + return WriteUtil.writeQuotedString(value, getTag()); } @Override - public boolean containsAttribute(MapInfo attributes) { - return attributes.getByteRange() != null; + public boolean containsAttribute(MapInfo mapInfo) { + return mapInfo.hasByteRange(); } }); } diff --git a/src/main/java/com/iheartradio/m3u8/ParseUtil.java b/src/main/java/com/iheartradio/m3u8/ParseUtil.java index 86b022a..71c5394 100644 --- a/src/main/java/com/iheartradio/m3u8/ParseUtil.java +++ b/src/main/java/com/iheartradio/m3u8/ParseUtil.java @@ -1,18 +1,14 @@ package com.iheartradio.m3u8; +import com.iheartradio.m3u8.data.ByteRange; +import com.iheartradio.m3u8.data.Resolution; + 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.*; import java.util.regex.Matcher; import java.util.regex.Pattern; -import com.iheartradio.m3u8.data.Resolution; - final class ParseUtil { public static int parseInt(String string, String tag) throws ParseException { try { @@ -137,6 +133,12 @@ public static String parseQuotedString(String quotedString, String tag) throws P 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'; } diff --git a/src/main/java/com/iheartradio/m3u8/PlaylistError.java b/src/main/java/com/iheartradio/m3u8/PlaylistError.java index 1700660..437c216 100644 --- a/src/main/java/com/iheartradio/m3u8/PlaylistError.java +++ b/src/main/java/com/iheartradio/m3u8/PlaylistError.java @@ -123,4 +123,10 @@ public enum PlaylistError { * 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/PlaylistValidation.java b/src/main/java/com/iheartradio/m3u8/PlaylistValidation.java index ea039ae..db894b5 100644 --- a/src/main/java/com/iheartradio/m3u8/PlaylistValidation.java +++ b/src/main/java/com/iheartradio/m3u8/PlaylistValidation.java @@ -4,6 +4,7 @@ import java.util.Collections; import java.util.HashSet; +import java.util.List; import java.util.Set; public class PlaylistValidation { @@ -98,11 +99,28 @@ private static void addMediaPlaylistErrors(MediaPlaylist playlist, Set 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); diff --git a/src/main/java/com/iheartradio/m3u8/TrackLineParser.java b/src/main/java/com/iheartradio/m3u8/TrackLineParser.java index daa1fa1..63eaef1 100644 --- a/src/main/java/com/iheartradio/m3u8/TrackLineParser.java +++ b/src/main/java/com/iheartradio/m3u8/TrackLineParser.java @@ -19,11 +19,13 @@ public void parse(String line, ParseState state) throws ParseException { .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/data/ByteRange.java b/src/main/java/com/iheartradio/m3u8/data/ByteRange.java index 8696f19..1344bae 100644 --- a/src/main/java/com/iheartradio/m3u8/data/ByteRange.java +++ b/src/main/java/com/iheartradio/m3u8/data/ByteRange.java @@ -3,27 +3,40 @@ import java.util.Objects; public class ByteRange { - private final int subRangeLength; - private final int offset; + private final long mSubRangeLength; + private final Long mOffset; - public ByteRange(int subRangeLength, int offset) { - this.subRangeLength = subRangeLength; - this.offset = offset; + public ByteRange(long subRangeLength, long offset) { + this.mSubRangeLength = subRangeLength; + this.mOffset = offset; } - public int getSubRangeLength() { - return subRangeLength; + public ByteRange(long subRangeLength, Long offset) { + this.mSubRangeLength = subRangeLength; + this.mOffset = offset; } - public int getOffset() { - return 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{" + - "subRangeLength=" + subRangeLength + - ", offset=" + offset + + "mSubRangeLength=" + mSubRangeLength + + ", mOffset=" + mOffset + '}'; } @@ -32,12 +45,12 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; ByteRange byteRange = (ByteRange) o; - return subRangeLength == byteRange.subRangeLength && - offset == byteRange.offset; + return mSubRangeLength == byteRange.mSubRangeLength && + Objects.equals(mOffset, byteRange.mOffset); } @Override public int hashCode() { - return Objects.hash(subRangeLength, offset); + return Objects.hash(mSubRangeLength, mOffset); } } diff --git a/src/main/java/com/iheartradio/m3u8/data/MapInfo.java b/src/main/java/com/iheartradio/m3u8/data/MapInfo.java index 5e636ee..b5d58f8 100644 --- a/src/main/java/com/iheartradio/m3u8/data/MapInfo.java +++ b/src/main/java/com/iheartradio/m3u8/data/MapInfo.java @@ -15,6 +15,10 @@ public String getUri() { return uri; } + public boolean hasByteRange() { + return byteRange != null; + } + public ByteRange getByteRange() { return byteRange; } diff --git a/src/main/java/com/iheartradio/m3u8/data/TrackData.java b/src/main/java/com/iheartradio/m3u8/data/TrackData.java index 4abeccf..8fa7c31 100644 --- a/src/main/java/com/iheartradio/m3u8/data/TrackData.java +++ b/src/main/java/com/iheartradio/m3u8/data/TrackData.java @@ -9,14 +9,16 @@ public class TrackData { 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) { + 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() { @@ -57,50 +59,56 @@ public EncryptionData getEncryptionData() { return mEncryptionData; } - public boolean hasMapInfo() { return mMapInfo != null; } - public Builder buildUpon() { - return new Builder(getUri(), mTrackInfo, mEncryptionData, mHasDiscontinuity); + public MapInfo getMapInfo() { + return mMapInfo; } - @Override - public int hashCode() { - return Objects.hash(mUri, mEncryptionData, mTrackInfo, mHasDiscontinuity); + public boolean hasByteRange() { + return mByteRange != null; } - @Override - public boolean equals(Object o) { - if (!(o instanceof TrackData)) { - return false; - } + public ByteRange getByteRange() { + return mByteRange; + } - TrackData other = (TrackData) o; + public Builder buildUpon() { + return new Builder(getUri(), mTrackInfo, mEncryptionData, mHasDiscontinuity, mMapInfo, mByteRange); + } - return Objects.equals(mUri, other.mUri) && - Objects.equals(mTrackInfo, other.mTrackInfo) && - Objects.equals(mEncryptionData, other.mEncryptionData) && - Objects.equals(mProgramDateTime, other.mProgramDateTime) && - Objects.equals(mHasDiscontinuity, other.mHasDiscontinuity); + @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 String toString() { - return new StringBuilder() - .append("(TrackData") - .append(" mUri=").append(mUri) - .append(" mTrackInfo=").append(mTrackInfo) - .append(" mEncryptionData=").append(mEncryptionData) - .append(" mProgramDateTime=").append(mProgramDateTime) - .append(" mHasDiscontinuity=").append(mHasDiscontinuity) - .append(")") - .toString(); + public int hashCode() { + return Objects.hash(mUri, mTrackInfo, mEncryptionData, mProgramDateTime, mHasDiscontinuity, mMapInfo, mByteRange); } - public MapInfo getMapInfo() { - return mMapInfo; + @Override + public String toString() { + return "TrackData{" + + "mUri='" + mUri + '\'' + + ", mTrackInfo=" + mTrackInfo + + ", mEncryptionData=" + mEncryptionData + + ", mProgramDateTime='" + mProgramDateTime + '\'' + + ", mHasDiscontinuity=" + mHasDiscontinuity + + ", mMapInfo=" + mMapInfo + + ", mByteRange=" + mByteRange + + '}'; } public static class Builder { @@ -110,15 +118,18 @@ public static class Builder { private String mProgramDateTime; private boolean mHasDiscontinuity; private MapInfo mMapInfo; + private ByteRange mByteRange; public Builder() { } - private Builder(String uri, TrackInfo trackInfo, EncryptionData encryptionData, boolean hasDiscontinuity) { + 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) { @@ -151,8 +162,13 @@ public Builder withMapInfo(MapInfo mapInfo) { return this; } + public Builder withByteRange(ByteRange byteRange) { + mByteRange = byteRange; + return this; + } + public TrackData build() { - return new TrackData(mUri, mTrackInfo, mEncryptionData, mProgramDateTime, mHasDiscontinuity, mMapInfo); + return new TrackData(mUri, mTrackInfo, mEncryptionData, mProgramDateTime, mHasDiscontinuity, mMapInfo, mByteRange); } } } diff --git a/src/test/java/com/iheartradio/m3u8/MediaPlaylistLineParserTest.java b/src/test/java/com/iheartradio/m3u8/MediaPlaylistLineParserTest.java index 8788a3a..c55ee62 100644 --- a/src/test/java/com/iheartradio/m3u8/MediaPlaylistLineParserTest.java +++ b/src/test/java/com/iheartradio/m3u8/MediaPlaylistLineParserTest.java @@ -1,5 +1,6 @@ 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; @@ -70,8 +71,8 @@ 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 int subRangeLength = 350; - final int offset = 76; + final long subRangeLength = 350; + final Long offset = 76L; final String line = "#" + tag + ":URI=\"" + uri + "\"" + @@ -85,4 +86,20 @@ public void testEXT_X_MAP() throws Exception { 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()); + } } diff --git a/src/test/java/com/iheartradio/m3u8/PlaylistParserWriterTest.java b/src/test/java/com/iheartradio/m3u8/PlaylistParserWriterTest.java index 13f8209..e07f41a 100644 --- a/src/test/java/com/iheartradio/m3u8/PlaylistParserWriterTest.java +++ b/src/test/java/com/iheartradio/m3u8/PlaylistParserWriterTest.java @@ -1,18 +1,16 @@ package com.iheartradio.m3u8; +import com.iheartradio.m3u8.data.*; +import org.junit.Test; + 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 com.iheartradio.m3u8.data.IFrameStreamInfo; -import org.junit.Test; - -import com.iheartradio.m3u8.data.MasterPlaylist; -import com.iheartradio.m3u8.data.Playlist; -import com.iheartradio.m3u8.data.PlaylistData; - import static org.junit.Assert.*; public class PlaylistParserWriterTest { @@ -172,5 +170,39 @@ public void discontinutyPlaylist() throws IOException, ParseException, PlaylistE System.out.println("***************"); System.out.println(sPlaylist); } - + + @Test + 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 index abb8a25..b62baae 100644 --- a/src/test/java/com/iheartradio/m3u8/PlaylistValidationTest.java +++ b/src/test/java/com/iheartradio/m3u8/PlaylistValidationTest.java @@ -4,6 +4,9 @@ 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; @@ -29,4 +32,15 @@ public void testAllowNegativeNumbersValidation() throws Exception { 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/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