Skip to content
This repository has been archived by the owner on Nov 22, 2022. It is now read-only.

Support for reading/writing EXT-X-BYTERANGE tag, Enable unknown tags when writing playlists #47

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 20 additions & 23 deletions src/main/java/com/iheartradio/m3u8/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
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);
Expand All @@ -20,26 +21,24 @@ final class Constants {
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 URI = "URI";

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 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 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";
Expand All @@ -53,26 +52,24 @@ final class Constants {
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_BYTE_RANGE_TAG = "EXT-X-BYTERANGE";
public static final String EXT_X_KEY_TAG = "EXT-X-KEY";
public static final String METHOD = "METHOD";
public static final String IV = "IV";
Expand All @@ -92,16 +89,16 @@ final class Constants {
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_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_BYTE_RANGE_PATTERN = Pattern.compile("^#" + EXT_X_BYTE_RANGE_TAG + EXT_TAG_END + "(" + INTEGER_REGEX + ")(@(" + INTEGER_REGEX + "))?$");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of:

"(@(" + INTEGER_REGEX + "))?$"

you can do:

"(?:@(" + INTEGER_REGEX + "))?$"

to not capture the second group with the '@'. Then the parsing becomes a little more straightforward.

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 + "$");

// other

// 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 = 5;
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/iheartradio/m3u8/ExtTagWriter.java
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public void write(TagWriter tagWriter, Playlist playlist) throws IOException {
List<String> unknownTags;
if (playlist.hasMasterPlaylist() && playlist.getMasterPlaylist().hasUnknownTags()) {
unknownTags = playlist.getMasterPlaylist().getUnknownTags();
} else if (playlist.getMediaPlaylist().hasUnknownTags()) {
} else if (playlist.hasMediaPlaylist() && playlist.getMediaPlaylist().hasUnknownTags()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

unknownTags = playlist.getMediaPlaylist().getUnknownTags();
} else {
unknownTags = Collections.emptyList();
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/iheartradio/m3u8/ExtendedM3uParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.iheartradio.m3u8.data.Playlist;

class ExtendedM3uParser extends BaseM3uParser {

private final ParsingMode mParsingMode;
private final Map<String, IExtTagParser> mExtTagParsers = new HashMap<String, IExtTagParser>();

Expand All @@ -31,6 +32,7 @@ class ExtendedM3uParser extends BaseM3uParser {
MasterPlaylistLineParser.EXT_X_STREAM_INF,
MasterPlaylistLineParser.EXT_X_I_FRAME_STREAM_INF,
MediaPlaylistLineParser.EXTINF,
MediaPlaylistLineParser.EXT_X_BYTE_RANGE,
MediaPlaylistLineParser.EXT_X_ENDLIST,
MediaPlaylistLineParser.EXT_X_DISCONTINUITY
);
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/com/iheartradio/m3u8/ExtendedM3uWriter.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@
import com.iheartradio.m3u8.data.Playlist;

class ExtendedM3uWriter extends Writer {
private List<SectionWriter> mExtTagWriter = new ArrayList<SectionWriter>();
private final List<SectionWriter> mExtTagWriter = new ArrayList<SectionWriter>();

public ExtendedM3uWriter(OutputStream outputStream, Encoding encoding) {
super(outputStream, encoding);
// Order influences output in file!
putWriters(
ExtTagWriter.EXTM3U_HANDLER,
ExtTagWriter.EXT_X_VERSION_HANDLER,
ExtTagWriter.EXT_UNKNOWN_HANDLER,
MediaPlaylistTagWriter.EXT_X_PLAYLIST_TYPE,
MediaPlaylistTagWriter.EXT_X_TARGETDURATION,
MediaPlaylistTagWriter.EXT_X_START,
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/com/iheartradio/m3u8/MediaParseState.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.util.ArrayList;
import java.util.List;

import com.iheartradio.m3u8.data.ByteRange;
import com.iheartradio.m3u8.data.EncryptionData;
import com.iheartradio.m3u8.data.MediaPlaylist;
import com.iheartradio.m3u8.data.PlaylistType;
Expand All @@ -11,6 +12,7 @@
import com.iheartradio.m3u8.data.TrackInfo;

class MediaParseState implements PlaylistParseState<MediaPlaylist> {

private List<String> mUnknownTags;
private StartData mStartData;

Expand All @@ -21,6 +23,7 @@ class MediaParseState implements PlaylistParseState<MediaPlaylist> {
public boolean isIframesOnly;
public PlaylistType playlistType;
public TrackInfo trackInfo;
public ByteRange byteRange;
public EncryptionData encryptionData;
public String programDateTime;
public boolean endOfList;
Expand Down
63 changes: 43 additions & 20 deletions src/main/java/com/iheartradio/m3u8/MediaPlaylistLineParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.util.Map;
import java.util.regex.Matcher;

import com.iheartradio.m3u8.data.ByteRange;
import com.iheartradio.m3u8.data.EncryptionData;
import com.iheartradio.m3u8.data.EncryptionData.Builder;
import com.iheartradio.m3u8.data.EncryptionMethod;
Expand All @@ -14,6 +15,7 @@
import com.iheartradio.m3u8.data.TrackInfo;

class MediaPlaylistLineParser implements LineParser {

private final IExtTagParser tagParser;
private final LineParser lineParser;

Expand All @@ -37,7 +39,6 @@ public void parse(String line, ParseState state) throws ParseException {
}

// media playlist tags

static final IExtTagParser EXT_X_ENDLIST = new IExtTagParser() {
private final LineParser lineParser = new MediaPlaylistLineParser(this);

Expand All @@ -59,7 +60,7 @@ public void parse(String line, ParseState state) throws ParseException {
state.getMedia().endOfList = true;
}
};

static final IExtTagParser EXT_X_I_FRAMES_ONLY = new IExtTagParser() {
private final LineParser lineParser = new MediaPlaylistLineParser(this);

Expand All @@ -76,17 +77,17 @@ public boolean hasData() {
@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);

Expand All @@ -99,7 +100,7 @@ public String getTag() {
public boolean hasData() {
return true;
}

@Override
public void parse(String line, ParseState state) throws ParseException {
lineParser.parse(line, state);
Expand All @@ -113,7 +114,6 @@ public void parse(String line, ParseState state) throws ParseException {
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);
Expand All @@ -127,7 +127,7 @@ public String getTag() {
public boolean hasData() {
return true;
}

@Override
public void parse(String line, ParseState state) throws ParseException {
lineParser.parse(line, state);
Expand All @@ -138,22 +138,22 @@ public void parse(String line, ParseState state) throws ParseException {
throw ParseException.create(ParseExceptionType.MULTIPLE_EXT_TAG_INSTANCES, getTag(), line);
}

state.getMedia().programDateTime = ParseUtil.parseDateTime(line,getTag());
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<String, AttributeParser<StartData.Builder>> HANDLERS = new HashMap<>();

{
HANDLERS.put(Constants.TIME_OFFSET, new AttributeParser<StartData.Builder>() {
@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<StartData.Builder>() {
@Override
public void parse(Attribute attribute, StartData.Builder builder, ParseState state) throws ParseException {
Expand All @@ -171,7 +171,7 @@ public String getTag() {
public boolean hasData() {
return true;
}

@Override
public void parse(String line, ParseState state) throws ParseException {
lineParser.parse(line, state);
Expand All @@ -183,7 +183,6 @@ public void parse(String line, ParseState state) throws ParseException {
state.getMedia().setStartData(startData);
}
};


static final IExtTagParser EXT_X_TARGETDURATION = new IExtTagParser() {
private final LineParser lineParser = new MediaPlaylistLineParser(this);
Expand Down Expand Up @@ -261,7 +260,6 @@ public void parse(String line, ParseState state) throws ParseException {
};

// media segment tags

static final IExtTagParser EXTINF = new IExtTagParser() {
private final LineParser lineParser = new MediaPlaylistLineParser(this);

Expand All @@ -285,6 +283,31 @@ public void parse(String line, ParseState state) throws ParseException {
}
};

static final IExtTagParser EXT_X_BYTE_RANGE = new IExtTagParser() {
private final LineParser lineParser = new MediaPlaylistLineParser(this);

@Override
public String getTag() {
return Constants.EXT_X_BYTE_RANGE_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_BYTE_RANGE_PATTERN, line, getTag());

if (matcher.groupCount() == 3) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not believe there is a case where the group count can be anything other than the total number of groups. Even if that group is empty, group count should return the same number. I think we can remove the if condition and keep the code in the block since it's guaranteed to have a match after the call to match above.

String offset = matcher.group(3);
state.getMedia().byteRange = new ByteRange(Integer.parseInt(matcher.group(1)), offset == null ? null : Integer.parseInt(offset));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spec says the following:

If o is not present, the sub-range begins at the next byte following
the sub-range of the previous Media Segment.

If o is not present, a previous Media Segment MUST appear in the
Playlist file and MUST be a sub-range of the same media resource, or
the Media Segment is undefined and the Playlist MUST be rejected.

This is a bit unfortunate since it means we will need to add a piece of state to the parsing, but it does mean there is no null case. We need to keep track of the last byte range (which may be nothing) to calculate the offset for the current byte range if no offset is specified. So it would look something like this:

offset = group(2);

if (offset is null) {
    if (previous is null) {
        throw;
    } else {
        offset = previous.offset + previous.subRangeLength;
    }
}

We're not quite done. Because of this:

... a previous Media Segment MUST appear in the
Playlist file and MUST be a sub-range of the same media resource ...

we need to clear the previous byte range when changing media sources.

}
}
};

static final IExtTagParser EXT_X_DISCONTINUITY = new IExtTagParser() {
private final LineParser lineParser = new MediaPlaylistLineParser(this);

Expand Down Expand Up @@ -336,8 +359,8 @@ public void parse(Attribute attribute, Builder builder, ParseState state) throws
public void parse(Attribute attribute, Builder builder, ParseState state) throws ParseException {
final List<Byte> initializationVector = ParseUtil.parseHexadecimal(attribute.value, getTag());

if ((initializationVector.size() != Constants.IV_SIZE) &&
(initializationVector.size() != Constants.IV_SIZE_ALTERNATIVE)) {
if ((initializationVector.size() != Constants.IV_SIZE)
&& (initializationVector.size() != Constants.IV_SIZE_ALTERNATIVE)) {
throw ParseException.create(ParseExceptionType.INVALID_IV_SIZE, getTag(), attribute.toString());
}

Expand Down Expand Up @@ -365,7 +388,7 @@ public void parse(Attribute attribute, Builder builder, ParseState state) throws
throw ParseException.create(ParseExceptionType.INVALID_KEY_FORMAT_VERSIONS, getTag(), attribute.toString());
}
}

builder.withKeyFormatVersions(versions);
}
});
Expand All @@ -380,7 +403,7 @@ public String getTag() {
public boolean hasData() {
return true;
}

@Override
public void parse(String line, ParseState state) throws ParseException {
lineParser.parse(line, state);
Expand Down
Loading