From 0c3d465bdbc4a3e6d2a518184059300d25ed215b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20Lindstr=C3=B6m?= Date: Fri, 20 Oct 2017 21:41:52 +0200 Subject: [PATCH] Add support for EXT-X-MAP and EXT-X-BYTERANGE (#52) * Add support for EXT-X-MAP * Supress deprecation warnings in ByteOrderMarkTest * Add support for EXT-X-BYTERANGE --- .../java/com/iheartradio/m3u8/Constants.java | 10 +- .../iheartradio/m3u8/ExtendedM3uParser.java | 8 +- .../com/iheartradio/m3u8/MediaParseState.java | 11 +-- .../m3u8/MediaPlaylistLineParser.java | 77 +++++++++++++-- .../m3u8/MediaPlaylistTagWriter.java | 95 +++++++++++++++++-- .../iheartradio/m3u8/ParseExceptionType.java | 1 + .../java/com/iheartradio/m3u8/ParseUtil.java | 18 ++-- .../com/iheartradio/m3u8/PlaylistError.java | 11 +++ .../iheartradio/m3u8/PlaylistValidation.java | 24 +++++ .../com/iheartradio/m3u8/TrackLineParser.java | 4 + .../com/iheartradio/m3u8/data/ByteRange.java | 56 +++++++++++ .../com/iheartradio/m3u8/data/MapInfo.java | 78 +++++++++++++++ .../com/iheartradio/m3u8/data/TrackData.java | 87 +++++++++++------ .../iheartradio/m3u8/ByteOrderMarkTest.java | 1 + .../m3u8/MediaPlaylistLineParserTest.java | 40 +++++++- .../m3u8/PlaylistParserWriterTest.java | 48 ++++++++-- .../m3u8/PlaylistValidationTest.java | 14 +++ .../mediaPlaylistWithByteRanges.m3u8 | 13 +++ .../mediaPlaylistWithInvalidByteRanges.m3u8 | 13 +++ 19 files changed, 540 insertions(+), 69 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 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 e57f3ad..a136c71 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"; @@ -79,6 +80,8 @@ 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"; + public static final String EXT_X_BYTERANGE_TAG = "EXT-X-BYTERANGE"; // regular expressions public static final String YES = "YES"; @@ -86,6 +89,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 + ")$"); @@ -100,7 +104,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_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}; diff --git a/src/main/java/com/iheartradio/m3u8/ExtendedM3uParser.java b/src/main/java/com/iheartradio/m3u8/ExtendedM3uParser.java index 72f3cca..f27e8a0 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,9 @@ 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, + 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 04e92fc..507d106 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,8 @@ class MediaParseState implements PlaylistParseState { public String programDateTime; 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 7429f8c..5dbaf5f 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,71 @@ 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()); + } + + 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 index b2bfd86..bfa3ff1 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,12 @@ 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()); } @@ -223,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>(); @@ -318,4 +335,70 @@ 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(); + 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/ParseExceptionType.java b/src/main/java/com/iheartradio/m3u8/ParseExceptionType.java index f11c5a5..d55ed75 100644 --- a/src/main/java/com/iheartradio/m3u8/ParseExceptionType.java +++ b/src/main/java/com/iheartradio/m3u8/ParseExceptionType.java @@ -20,6 +20,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/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 ce59710..437c216 100644 --- a/src/main/java/com/iheartradio/m3u8/PlaylistError.java +++ b/src/main/java/com/iheartradio/m3u8/PlaylistError.java @@ -118,4 +118,15 @@ 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, + + /** + * 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 78a7980..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); @@ -196,5 +214,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..63eaef1 100644 --- a/src/main/java/com/iheartradio/m3u8/TrackLineParser.java +++ b/src/main/java/com/iheartradio/m3u8/TrackLineParser.java @@ -18,10 +18,14 @@ public void parse(String line, ParseState state) throws ParseException { .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/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/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/TrackData.java b/src/main/java/com/iheartradio/m3u8/data/TrackData.java index be0b2e1..8fa7c31 100644 --- a/src/main/java/com/iheartradio/m3u8/data/TrackData.java +++ b/src/main/java/com/iheartradio/m3u8/data/TrackData.java @@ -8,13 +8,17 @@ public class TrackData { 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) { + 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() { @@ -55,41 +59,56 @@ public EncryptionData getEncryptionData() { return mEncryptionData; } - public Builder buildUpon() { - return new Builder(getUri(), mTrackInfo, mEncryptionData, mHasDiscontinuity); + public boolean hasMapInfo() { + return mMapInfo != null; } - @Override - public int hashCode() { - return Objects.hash(mUri, mEncryptionData, mTrackInfo, mHasDiscontinuity); + 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 (!(o instanceof TrackData)) { - return false; - } - - TrackData other = (TrackData) 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); + } - 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 int hashCode() { + return Objects.hash(mUri, mTrackInfo, mEncryptionData, mProgramDateTime, mHasDiscontinuity, mMapInfo, 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(); + return "TrackData{" + + "mUri='" + mUri + '\'' + + ", mTrackInfo=" + mTrackInfo + + ", mEncryptionData=" + mEncryptionData + + ", mProgramDateTime='" + mProgramDateTime + '\'' + + ", mHasDiscontinuity=" + mHasDiscontinuity + + ", mMapInfo=" + mMapInfo + + ", mByteRange=" + mByteRange + + '}'; } public static class Builder { @@ -98,15 +117,19 @@ public static class Builder { 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) { + 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) { @@ -134,8 +157,18 @@ public Builder withDiscontinuity(boolean 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); + return new TrackData(mUri, mTrackInfo, mEncryptionData, mProgramDateTime, mHasDiscontinuity, mMapInfo, mByteRange); } } } 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; diff --git a/src/test/java/com/iheartradio/m3u8/MediaPlaylistLineParserTest.java b/src/test/java/com/iheartradio/m3u8/MediaPlaylistLineParserTest.java index 3896c48..c55ee62 100644 --- a/src/test/java/com/iheartradio/m3u8/MediaPlaylistLineParserTest.java +++ b/src/test/java/com/iheartradio/m3u8/MediaPlaylistLineParserTest.java @@ -1,8 +1,9 @@ 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; @@ -64,4 +65,41 @@ 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 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()); + } } 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