diff --git a/RELEASENOTES.md b/RELEASENOTES.md
index 029c5f9e67d..ccd16d808c1 100644
--- a/RELEASENOTES.md
+++ b/RELEASENOTES.md
@@ -48,6 +48,8 @@
* Fix playlist parsing to accept `\f` (form feed) in quoted string
attribute values
([#2420](https://github.com/androidx/media/issues/2420)).
+ * Support updating interstitials with the same ID
+ ([#2427](https://github.com/androidx/media/pull/2427)).
* DASH extension:
* Smooth Streaming extension:
* RTSP extension:
diff --git a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/HlsMediaPlaylist.java b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/HlsMediaPlaylist.java
index 0c7a6b27a5d..c0f917aff69 100644
--- a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/HlsMediaPlaylist.java
+++ b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/HlsMediaPlaylist.java
@@ -33,14 +33,17 @@
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
/** Represents an HLS media playlist. */
@UnstableApi
@@ -565,7 +568,10 @@ public Interstitial(
this.playoutLimitUs = playoutLimitUs;
this.snapTypes = ImmutableList.copyOf(snapTypes);
this.restrictions = ImmutableList.copyOf(restrictions);
- this.clientDefinedAttributes = ImmutableList.copyOf(clientDefinedAttributes);
+ // Sort to ensure equality decoupled from how exactly parsing is implemented.
+ this.clientDefinedAttributes =
+ ImmutableList.sortedCopyOf(
+ (o1, o2) -> o1.name.compareTo(o2.name), clientDefinedAttributes);
}
@Override
@@ -611,6 +617,367 @@ public int hashCode() {
restrictions,
clientDefinedAttributes);
}
+
+ /**
+ * Builder for {@link Interstitial}.
+ *
+ *
See RFC 8216bis, section 4.4.5.1 for how to consolidate multiple interstitials with the
+ * same {@linkplain HlsMediaPlaylist.Interstitial#id ID}.
+ */
+ public static final class Builder {
+
+ private final String id;
+ private final Map clientDefinedAttributes;
+
+ private @MonotonicNonNull Uri assetUri;
+ private @MonotonicNonNull Uri assetListUri;
+ private long startDateUnixUs;
+ private long endDateUnixUs;
+ private long durationUs;
+ private long plannedDurationUs;
+ private List<@Interstitial.CueTriggerType String> cue;
+ private boolean endOnNext;
+ private long resumeOffsetUs;
+ private long playoutLimitUs;
+ private List<@Interstitial.SnapType String> snapTypes;
+ private List<@Interstitial.NavigationRestriction String> restrictions;
+
+ /**
+ * Creates the builder.
+ *
+ * @param id The id.
+ */
+ public Builder(String id) {
+ this.id = id;
+ clientDefinedAttributes = new HashMap<>();
+ startDateUnixUs = C.TIME_UNSET;
+ endDateUnixUs = C.TIME_UNSET;
+ durationUs = C.TIME_UNSET;
+ plannedDurationUs = C.TIME_UNSET;
+ cue = new ArrayList<>();
+ resumeOffsetUs = C.TIME_UNSET;
+ playoutLimitUs = C.TIME_UNSET;
+ snapTypes = new ArrayList<>();
+ restrictions = new ArrayList<>();
+ }
+
+ /**
+ * Sets the asset URI.
+ *
+ * @throws IllegalArgumentException if called with a non-null value that is different to the
+ * value previously set.
+ */
+ @CanIgnoreReturnValue
+ public Builder setAssetUri(@Nullable Uri assetUri) {
+ if (assetUri == null) {
+ return this;
+ }
+ if (this.assetUri != null) {
+ checkArgument(
+ this.assetUri.equals(assetUri),
+ "Can't change assetUri from " + this.assetUri + " to " + assetUri);
+ }
+ this.assetUri = assetUri;
+ return this;
+ }
+
+ /**
+ * Sets the asset list URI.
+ *
+ * @throws IllegalArgumentException if called with a non-null value that is different to the
+ * value previously set.
+ */
+ @CanIgnoreReturnValue
+ public Builder setAssetListUri(@Nullable Uri assetListUri) {
+ if (assetListUri == null) {
+ return this;
+ }
+ if (this.assetListUri != null) {
+ checkArgument(
+ this.assetListUri.equals(assetListUri),
+ "Can't change assetListUri from " + this.assetListUri + " to " + assetListUri);
+ }
+ this.assetListUri = assetListUri;
+ return this;
+ }
+
+ /**
+ * Sets the start date as a unix epoch timestamp, in microseconds.
+ *
+ * @throws IllegalArgumentException if called with a value different to {@link C#TIME_UNSET}
+ * and different to the value previously set.
+ */
+ @CanIgnoreReturnValue
+ public Builder setStartDateUnixUs(long startDateUnixUs) {
+ if (startDateUnixUs == C.TIME_UNSET) {
+ return this;
+ }
+ if (this.startDateUnixUs != C.TIME_UNSET) {
+ checkArgument(
+ this.startDateUnixUs == startDateUnixUs,
+ "Can't change startDateUnixUs from "
+ + this.startDateUnixUs
+ + " to "
+ + startDateUnixUs);
+ }
+ this.startDateUnixUs = startDateUnixUs;
+ return this;
+ }
+
+ /**
+ * Sets the end date as a unix epoch timestamp, in microseconds.
+ *
+ * @throws IllegalArgumentException if called with a value different to {@link C#TIME_UNSET}
+ * and different to the value previously set.
+ */
+ @CanIgnoreReturnValue
+ public Builder setEndDateUnixUs(long endDateUnixUs) {
+ if (endDateUnixUs == C.TIME_UNSET) {
+ return this;
+ }
+ if (this.endDateUnixUs != C.TIME_UNSET) {
+ checkArgument(
+ this.endDateUnixUs == endDateUnixUs,
+ "Can't change endDateUnixUs from " + this.endDateUnixUs + " to " + endDateUnixUs);
+ }
+ this.endDateUnixUs = endDateUnixUs;
+ return this;
+ }
+
+ /**
+ * Sets the duration, in microseconds.
+ *
+ * @throws IllegalArgumentException if called with a value different to {@link C#TIME_UNSET}
+ * and different to the value previously set.
+ */
+ @CanIgnoreReturnValue
+ public Builder setDurationUs(long durationUs) {
+ if (durationUs == C.TIME_UNSET) {
+ return this;
+ }
+ if (this.durationUs != C.TIME_UNSET) {
+ checkArgument(
+ this.durationUs == durationUs,
+ "Can't change durationUs from " + this.durationUs + " to " + durationUs);
+ }
+ this.durationUs = durationUs;
+ return this;
+ }
+
+ /**
+ * Sets the planned duration, in microseconds.
+ *
+ * @throws IllegalArgumentException if called with a value different to {@link C#TIME_UNSET}
+ * and different to the value previously set.
+ */
+ @CanIgnoreReturnValue
+ public Builder setPlannedDurationUs(long plannedDurationUs) {
+ if (plannedDurationUs == C.TIME_UNSET) {
+ return this;
+ }
+ if (this.plannedDurationUs != C.TIME_UNSET) {
+ checkArgument(
+ this.plannedDurationUs == plannedDurationUs,
+ "Can't change plannedDurationUs from "
+ + this.plannedDurationUs
+ + " to "
+ + plannedDurationUs);
+ }
+ this.plannedDurationUs = plannedDurationUs;
+ return this;
+ }
+
+ /**
+ * Sets the {@linkplain Interstitial.CueTriggerType cue trigger types}.
+ *
+ * @throws IllegalArgumentException if called with a value different to {@link C#TIME_UNSET}
+ * and different to the value previously set.
+ */
+ @CanIgnoreReturnValue
+ public Builder setCue(List<@Interstitial.CueTriggerType String> cue) {
+ if (cue.isEmpty()) {
+ return this;
+ }
+ if (!this.cue.isEmpty()) {
+ checkArgument(
+ this.cue.equals(cue),
+ "Can't change cue from "
+ + String.join(", ", this.cue)
+ + " to "
+ + String.join(", ", cue));
+ }
+ this.cue = cue;
+ return this;
+ }
+
+ /**
+ * Sets whether the interstitial ends on the start time of the next interstitial.
+ *
+ * Once set to true, it can't be reset to false and doing so would be ignored.
+ */
+ @CanIgnoreReturnValue
+ public Builder setEndOnNext(boolean endOnNext) {
+ if (!endOnNext) {
+ return this;
+ }
+ this.endOnNext = true;
+ return this;
+ }
+
+ /**
+ * Sets the resume offset, in microseconds.
+ *
+ * @throws IllegalArgumentException if called with a value different to {@link C#TIME_UNSET}
+ * and different to the value previously set.
+ */
+ @CanIgnoreReturnValue
+ public Builder setResumeOffsetUs(long resumeOffsetUs) {
+ if (resumeOffsetUs == C.TIME_UNSET) {
+ return this;
+ }
+ if (this.resumeOffsetUs != C.TIME_UNSET) {
+ checkArgument(
+ this.resumeOffsetUs == resumeOffsetUs,
+ "Can't change resumeOffsetUs from " + this.resumeOffsetUs + " to " + resumeOffsetUs);
+ }
+ this.resumeOffsetUs = resumeOffsetUs;
+ return this;
+ }
+
+ /**
+ * Sets the play out limit, in microseconds.
+ *
+ * @throws IllegalArgumentException if called with a value different to {@link C#TIME_UNSET}
+ * and different to the value previously set.
+ */
+ @CanIgnoreReturnValue
+ public Builder setPlayoutLimitUs(long playoutLimitUs) {
+ if (playoutLimitUs == C.TIME_UNSET) {
+ return this;
+ }
+ if (this.playoutLimitUs != C.TIME_UNSET) {
+ checkArgument(
+ this.playoutLimitUs == playoutLimitUs,
+ "Can't change playoutLimitUs from " + this.playoutLimitUs + " to " + playoutLimitUs);
+ }
+ this.playoutLimitUs = playoutLimitUs;
+ return this;
+ }
+
+ /**
+ * Sets the {@linkplain Interstitial.SnapType snap types}.
+ *
+ * @throws IllegalArgumentException if called with a non-empty list of snap types that is not
+ * equal to the non-empty list that was previously set.
+ */
+ @CanIgnoreReturnValue
+ public Builder setSnapTypes(List<@Interstitial.SnapType String> snapTypes) {
+ if (snapTypes.isEmpty()) {
+ return this;
+ }
+ if (!this.snapTypes.isEmpty()) {
+ checkArgument(
+ this.snapTypes.equals(snapTypes),
+ "Can't change snapTypes from "
+ + String.join(", ", this.snapTypes)
+ + " to "
+ + String.join(", ", snapTypes));
+ }
+ this.snapTypes = snapTypes;
+ return this;
+ }
+
+ /**
+ * Sets the {@link NavigationRestriction navigation restrictions}.
+ *
+ * @throws IllegalArgumentException if called with a non-empty list of restrictions that is
+ * not equal to the non-empty list that was previously set.
+ */
+ @CanIgnoreReturnValue
+ public Builder setRestrictions(
+ List<@Interstitial.NavigationRestriction String> restrictions) {
+ if (restrictions.isEmpty()) {
+ return this;
+ }
+ if (!this.restrictions.isEmpty()) {
+ checkArgument(
+ this.restrictions.equals(restrictions),
+ "Can't change restrictions from "
+ + String.join(", ", this.restrictions)
+ + " to "
+ + String.join(", ", restrictions));
+ }
+ this.restrictions = restrictions;
+ return this;
+ }
+
+ /**
+ * Sets the {@linkplain ClientDefinedAttribute client defined attributes}.
+ *
+ *
Equal duplicates are ignored, new attributes are added to those already set.
+ *
+ * @throws IllegalArgumentException if called with a list containing a client defined
+ * attribute that is not equal with an attribute previously set with the same {@linkplain
+ * ClientDefinedAttribute#name name}.
+ */
+ @CanIgnoreReturnValue
+ public Builder setClientDefinedAttributes(
+ List clientDefinedAttributes) {
+ if (clientDefinedAttributes.isEmpty()) {
+ return this;
+ }
+ for (int i = 0; i < clientDefinedAttributes.size(); i++) {
+ ClientDefinedAttribute newAttribute = clientDefinedAttributes.get(i);
+ String newName = newAttribute.name;
+ ClientDefinedAttribute existingAttribute = this.clientDefinedAttributes.get(newName);
+ if (existingAttribute != null) {
+ checkArgument(
+ existingAttribute.equals(newAttribute),
+ "Can't change "
+ + newName
+ + " from "
+ + existingAttribute.textValue
+ + " "
+ + existingAttribute.doubleValue
+ + " to "
+ + newAttribute.textValue
+ + " "
+ + newAttribute.doubleValue);
+ }
+ this.clientDefinedAttributes.put(newName, newAttribute);
+ }
+ return this;
+ }
+
+ /**
+ * Builds and returns a new {@link Interstitial} instance or null if validation of the
+ * properties fails. The properties are considered invalid, if the start date is missing or
+ * both asset URI and asset list URI are set at the same time.
+ */
+ @Nullable
+ public Interstitial build() {
+ if (((assetListUri == null && assetUri != null)
+ || (assetListUri != null && assetUri == null))
+ && startDateUnixUs != C.TIME_UNSET) {
+ return new Interstitial(
+ id,
+ assetUri,
+ assetListUri,
+ startDateUnixUs,
+ endDateUnixUs,
+ durationUs,
+ plannedDurationUs,
+ cue,
+ endOnNext,
+ resumeOffsetUs,
+ playoutLimitUs,
+ snapTypes,
+ restrictions,
+ new ArrayList<>(clientDefinedAttributes.values()));
+ }
+ return null;
+ }
+ }
}
/** A client defined attribute. See RFC 8216bis, section 4.4.5.1. */
diff --git a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/HlsPlaylistParser.java b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/HlsPlaylistParser.java
index d0aa3bdca3a..2d2559eac56 100644
--- a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/HlsPlaylistParser.java
+++ b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/HlsPlaylistParser.java
@@ -50,7 +50,6 @@
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist.Variant;
import androidx.media3.exoplayer.upstream.ParsingLoadable;
import androidx.media3.extractor.mp4.PsshAtomUtil;
-import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import java.io.BufferedReader;
import java.io.IOException;
@@ -62,6 +61,7 @@
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
@@ -758,7 +758,7 @@ private static HlsMediaPlaylist parseMediaPlaylist(
@Nullable Part preloadPart = null;
List renditionReports = new ArrayList<>();
List tags = new ArrayList<>();
- List interstitials = new ArrayList<>();
+ LinkedHashMap interstitialBuilderMap = new LinkedHashMap<>();
long segmentDurationUs = 0;
String segmentTitle = "";
@@ -1079,8 +1079,13 @@ && parseOptionalStringAttr(line, REGEX_CLASS, /* defaultValue= */ "", variableDe
if (assetListUriString != null) {
assetListUri = Uri.parse(assetListUriString);
}
- long startDateUnixUs =
- msToUs(parseXsDateTime(parseStringAttr(line, REGEX_START_DATE, variableDefinitions)));
+ long startDateUnixUs = C.TIME_UNSET;
+ @Nullable
+ String startDateUnixMsString =
+ parseOptionalStringAttr(line, REGEX_START_DATE, variableDefinitions);
+ if (startDateUnixMsString != null) {
+ startDateUnixUs = msToUs(parseXsDateTime(startDateUnixMsString));
+ }
long endDateUnixUs = C.TIME_UNSET;
@Nullable
String endDateUnixMsString =
@@ -1161,8 +1166,7 @@ && parseOptionalStringAttr(line, REGEX_CLASS, /* defaultValue= */ "", variableDe
}
}
- ImmutableList.Builder clientDefinedAttributes =
- new ImmutableList.Builder<>();
+ List clientDefinedAttributes = new ArrayList<>();
String attributes = line.substring("#EXT-X-DATERANGE:".length());
Matcher matcher = REGEX_CLIENT_DEFINED_ATTRIBUTE_PREFIX.matcher(attributes);
while (matcher.find()) {
@@ -1185,25 +1189,25 @@ && parseOptionalStringAttr(line, REGEX_CLASS, /* defaultValue= */ "", variableDe
break;
}
}
- if ((assetListUri == null && assetUri != null)
- || (assetListUri != null && assetUri == null)) {
- interstitials.add(
- new Interstitial(
- id,
- assetUri,
- assetListUri,
- startDateUnixUs,
- endDateUnixUs,
- durationUs,
- plannedDurationUs,
- cue,
- endOnNext,
- resumeOffsetUs,
- playoutLimitUs,
- snapTypes,
- restrictions,
- clientDefinedAttributes.build()));
- }
+
+ Interstitial.Builder interstitialBuilder =
+ (interstitialBuilderMap.containsKey(id)
+ ? interstitialBuilderMap.get(id)
+ : new Interstitial.Builder(id))
+ .setAssetUri(assetUri)
+ .setAssetListUri(assetListUri)
+ .setStartDateUnixUs(startDateUnixUs)
+ .setEndDateUnixUs(endDateUnixUs)
+ .setDurationUs(durationUs)
+ .setPlannedDurationUs(plannedDurationUs)
+ .setCue(cue)
+ .setEndOnNext(endOnNext)
+ .setResumeOffsetUs(resumeOffsetUs)
+ .setPlayoutLimitUs(playoutLimitUs)
+ .setSnapTypes(snapTypes)
+ .setRestrictions(restrictions)
+ .setClientDefinedAttributes(clientDefinedAttributes);
+ interstitialBuilderMap.put(id, interstitialBuilder);
} else if (!line.startsWith("#")) {
@Nullable
String segmentEncryptionIV =
@@ -1289,6 +1293,14 @@ && parseOptionalStringAttr(line, REGEX_CLASS, /* defaultValue= */ "", variableDe
trailingParts.add(preloadPart);
}
+ List interstitials = new ArrayList<>();
+ for (Interstitial.Builder interstitialBuilder : interstitialBuilderMap.values()) {
+ Interstitial interstitial = interstitialBuilder.build();
+ if (interstitial != null) {
+ interstitials.add(interstitial);
+ }
+ }
+
return new HlsMediaPlaylist(
playlistType,
baseUri,
diff --git a/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/playlist/HlsMediaPlaylistParserTest.java b/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/playlist/HlsMediaPlaylistParserTest.java
index 9cc3807e079..b1209895bf2 100644
--- a/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/playlist/HlsMediaPlaylistParserTest.java
+++ b/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/playlist/HlsMediaPlaylistParserTest.java
@@ -1197,12 +1197,12 @@ public void parseMediaPlaylist_withInterstitialDateRanges() throws IOException,
/* snapTypes= */ ImmutableList.of(),
/* restrictions= */ ImmutableList.of(),
/* clientDefinedAttributes= */ ImmutableList.of(
+ new HlsMediaPlaylist.ClientDefinedAttribute("X-GOOGLE-TEST-DOUBLE1", 12.123d),
+ new HlsMediaPlaylist.ClientDefinedAttribute("X-GOOGLE-TEST-DOUBLE2", 1d),
new HlsMediaPlaylist.ClientDefinedAttribute(
"X-GOOGLE-TEST-HEX",
"0XAB10A",
- HlsMediaPlaylist.ClientDefinedAttribute.TYPE_HEX_TEXT),
- new HlsMediaPlaylist.ClientDefinedAttribute("X-GOOGLE-TEST-DOUBLE1", 12.123d),
- new HlsMediaPlaylist.ClientDefinedAttribute("X-GOOGLE-TEST-DOUBLE2", 1d)));
+ HlsMediaPlaylist.ClientDefinedAttribute.TYPE_HEX_TEXT)));
InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString));
HlsMediaPlaylist playlist =
@@ -1445,7 +1445,7 @@ public void parseMediaPlaylist_withDateRangeWithMissingClass_dateRangeIgnored()
}
@Test
- public void parseMediaPlaylist_withInterstitialWithoutStartDate_throwsParserException() {
+ public void parseMediaPlaylist_interstitialWithoutStartDate_ignored() throws IOException {
Uri playlistUri = Uri.parse("https://example.com/test.m3u8");
String playlistString =
"#EXTM3U\n"
@@ -1461,11 +1461,12 @@ public void parseMediaPlaylist_withInterstitialWithoutStartDate_throwsParserExce
+ "X-ASSET-LIST=\"http://example.com/ad2-assets.json\"\n";
HlsPlaylistParser hlsPlaylistParser = new HlsPlaylistParser();
- assertThrows(
- ParserException.class,
- () ->
+ HlsMediaPlaylist playlist =
+ (HlsMediaPlaylist)
hlsPlaylistParser.parse(
- playlistUri, new ByteArrayInputStream(Util.getUtf8Bytes(playlistString))));
+ playlistUri, new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)));
+
+ assertThat(playlist.interstitials).isEmpty();
}
@Test
@@ -1609,6 +1610,169 @@ public void parseMediaPlaylist_withInterstitialWithAssetUriAndList_interstitialI
assertThat(playlist.interstitials.get(0).snapTypes).containsExactly(SNAP_TYPE_OUT);
}
+ @Test
+ public void parseMediaPlaylist_withInterstitialWithUpdatingDateRange() throws IOException {
+ Uri playlistUri = Uri.parse("https://example.com/test.m3u8");
+ String playlistString =
+ "#EXTM3U\n"
+ + "#EXT-X-VERSION:3\n"
+ + "#EXT-X-TARGETDURATION:10\n"
+ + "#EXT-X-MEDIA-SEQUENCE:0\n"
+ + "#EXT-X-PROGRAM-DATE-TIME:2024-09-20T15:29:20.000Z\n"
+ + "#EXTINF:10.007800,\n"
+ + "audio0000.ts"
+ + "\n"
+ + "#EXT-X-DATERANGE:ID=\"15943\","
+ + "CLASS=\"com.apple.hls.interstitial\","
+ + "START-DATE=\"2024-09-20T15:29:24.006Z\","
+ + "PLANNED-DURATION=25,"
+ + "X-ASSET-LIST=\"myapp://interstitial/req?_HLS_interstitial_id=15943\","
+ + "X-SNAP=\"OUT,IN\","
+ + "X-TIMELINE-OCCUPIES=\"RANGE\","
+ + "X-TIMELINE-STYLE=\"HIGHLIGHT\","
+ + "X-CONTENT-MAY-VARY=\"YES\""
+ + "\n"
+ + "#EXTINF:10.007800,\n"
+ + "audio0001.ts"
+ + "\n"
+ + "#EXT-X-DATERANGE:ID=\"15943\","
+ + "CLASS=\"com.apple.hls.interstitial\","
+ + "END-DATE=\"2024-09-20T15:29:49.006Z\","
+ + "X-PLAYOUT-LIMIT=24.953741497,"
+ + "X-RESUME-OFFSET=24.953741497\n";
+
+ HlsMediaPlaylist playlist =
+ (HlsMediaPlaylist)
+ new HlsPlaylistParser()
+ .parse(playlistUri, new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)));
+
+ assertThat(playlist.interstitials).hasSize(1);
+ assertThat(playlist.interstitials.get(0).resumeOffsetUs).isEqualTo(24953741L);
+ assertThat(playlist.interstitials.get(0).endDateUnixUs).isEqualTo(1726846189006000L);
+ assertThat(playlist.interstitials.get(0).playoutLimitUs).isEqualTo(24953741L);
+ }
+
+ @Test
+ public void
+ parseMediaPlaylist_withInterstitialStartDateInvalidUpdate_throwsIllegalArgumentException() {
+ Uri playlistUri = Uri.parse("https://example.com/test.m3u8");
+ String playlistString =
+ "#EXTM3U\n"
+ + "#EXT-X-VERSION:3\n"
+ + "#EXT-X-TARGETDURATION:10\n"
+ + "#EXT-X-MEDIA-SEQUENCE:0\n"
+ + "#EXT-X-PROGRAM-DATE-TIME:2024-09-20T15:29:20.000Z\n"
+ + "#EXTINF:10.007800,\n"
+ + "audio0000.ts"
+ + "\n"
+ + "#EXT-X-DATERANGE:ID=\"15943\","
+ + "CLASS=\"com.apple.hls.interstitial\","
+ + "START-DATE=\"2024-09-20T15:29:24.006Z\","
+ + "PLANNED-DURATION=25,"
+ + "X-ASSET-LIST=\"myapp://interstitial/req?_HLS_interstitial_id=15943\","
+ + "X-SNAP=\"OUT,IN\","
+ + "X-TIMELINE-OCCUPIES=\"RANGE\","
+ + "X-TIMELINE-STYLE=\"HIGHLIGHT\","
+ + "X-CONTENT-MAY-VARY=\"YES\""
+ + "\n"
+ + "#EXTINF:10.007800,\n"
+ + "audio0001.ts"
+ + "\n"
+ + "#EXT-X-DATERANGE:ID=\"15943\","
+ + "CLASS=\"com.apple.hls.interstitial\","
+ + "START-DATE=\"2024-09-20T15:29:25.006Z\","
+ + "END-DATE=\"2024-09-20T15:29:49.006Z\","
+ + "X-PLAYOUT-LIMIT=24.953741497,"
+ + "X-RESUME-OFFSET=24.953741497\n";
+ HlsPlaylistParser hlsPlaylistParser = new HlsPlaylistParser();
+ ByteArrayInputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString));
+
+ assertThrows(
+ IllegalArgumentException.class, () -> hlsPlaylistParser.parse(playlistUri, inputStream));
+ }
+
+ @Test
+ public void
+ parseMediaPlaylist_withInterstitialClientDefinedAttributeInvalidUpdate_throwsIllegalArgumentException() {
+ Uri playlistUri = Uri.parse("https://example.com/test.m3u8");
+ String playlistString =
+ "#EXTM3U\n"
+ + "#EXT-X-VERSION:3\n"
+ + "#EXT-X-TARGETDURATION:10\n"
+ + "#EXT-X-MEDIA-SEQUENCE:0\n"
+ + "#EXT-X-PROGRAM-DATE-TIME:2024-09-20T15:29:20.000Z\n"
+ + "#EXTINF:10.007800,\n"
+ + "audio0000.ts"
+ + "\n"
+ + "#EXT-X-DATERANGE:ID=\"15943\","
+ + "CLASS=\"com.apple.hls.interstitial\","
+ + "START-DATE=\"2024-09-20T15:29:24.006Z\","
+ + "PLANNED-DURATION=25,"
+ + "X-ASSET-LIST=\"myapp://interstitial/req?_HLS_interstitial_id=15943\","
+ + "X-SNAP=\"OUT,IN\","
+ + "X-TIMELINE-OCCUPIES=\"RANGE\","
+ + "X-TIMELINE-STYLE=\"HIGHLIGHT\","
+ + "X-CONTENT-MAY-VARY=\"YES\""
+ + "\n"
+ + "#EXTINF:10.007800,\n"
+ + "audio0001.ts"
+ + "\n"
+ + "#EXT-X-DATERANGE:ID=\"15943\","
+ + "CLASS=\"com.apple.hls.interstitial\","
+ + "END-DATE=\"2024-09-20T15:29:49.006Z\","
+ + "X-PLAYOUT-LIMIT=24.953741497,"
+ + "X-CONTENT-MAY-VARY=\"NO\","
+ + "X-RESUME-OFFSET=24.953741497\n";
+ HlsPlaylistParser hlsPlaylistParser = new HlsPlaylistParser();
+ ByteArrayInputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString));
+
+ assertThrows(
+ IllegalArgumentException.class, () -> hlsPlaylistParser.parse(playlistUri, inputStream));
+ }
+
+ @Test
+ public void parseMediaPlaylist_withInterstitialClientDefinedAttribute() throws IOException {
+ Uri playlistUri = Uri.parse("https://example.com/test.m3u8");
+ String playlistString =
+ "#EXTM3U\n"
+ + "#EXT-X-VERSION:3\n"
+ + "#EXT-X-TARGETDURATION:10\n"
+ + "#EXT-X-MEDIA-SEQUENCE:0\n"
+ + "#EXT-X-PROGRAM-DATE-TIME:2024-09-20T15:29:20.000Z\n"
+ + "#EXTINF:10.007800,\n"
+ + "audio0000.ts\n"
+ + "#EXT-X-DATERANGE:ID=\"15943\","
+ + "CLASS=\"com.apple.hls.interstitial\","
+ + "START-DATE=\"2024-09-20T15:29:24.006Z\","
+ + "PLANNED-DURATION=25,"
+ + "X-ASSET-LIST=\"myapp://interstitial/req?_HLS_interstitial_id=15943\","
+ + "X-SNAP=\"OUT,IN\","
+ + "X-CONTENT-MAY-VARY=\"YES\""
+ + "\n"
+ + "#EXTINF:10.007800,\n"
+ + "audio0001.ts"
+ + "\n"
+ + "#EXT-X-DATERANGE:ID=\"15943\","
+ + "CLASS=\"com.apple.hls.interstitial\","
+ + "END-DATE=\"2024-09-20T15:29:49.006Z\","
+ + "X-PLAYOUT-LIMIT=24.953741497,"
+ + "X-CONTENT-MAY-VARY=\"YES\","
+ + "X-RESUME-OFFSET=24.953741497\n";
+
+ HlsMediaPlaylist playlist =
+ (HlsMediaPlaylist)
+ new HlsPlaylistParser()
+ .parse(playlistUri, new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)));
+
+ assertThat(playlist.interstitials).hasSize(1);
+ ImmutableList clientDefinedAttributes =
+ playlist.interstitials.get(0).clientDefinedAttributes;
+ assertThat(clientDefinedAttributes).hasSize(1);
+ HlsMediaPlaylist.ClientDefinedAttribute clientDefinedAttribute = clientDefinedAttributes.get(0);
+ assertThat(clientDefinedAttribute.name).isEqualTo("X-CONTENT-MAY-VARY");
+ assertThat(clientDefinedAttribute.getTextValue()).isEqualTo("YES");
+ }
+
@Test
public void multipleExtXKeysForSingleSegment() throws Exception {
Uri playlistUri = Uri.parse("https://example.com/test.m3u8");