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