From 6453054878026a1703ba7a80c8dd510c63d58c53 Mon Sep 17 00:00:00 2001 From: bachinger Date: Thu, 14 Nov 2024 03:05:47 -0800 Subject: [PATCH] Parse interstitials into HLS media playlist PiperOrigin-RevId: 696454575 --- .../hls/playlist/HlsMediaPlaylist.java | 323 +++++++++++++- .../hls/playlist/HlsPlaylistParser.java | 206 ++++++++- .../playlist/HlsMediaPlaylistParserTest.java | 413 ++++++++++++++++++ .../interstitials_vod_asset_uri.m3u8 | 47 ++ 4 files changed, 982 insertions(+), 7 deletions(-) create mode 100644 libraries/test_data/src/test/assets/media/hls/interstitials/interstitials_vod_asset_uri.m3u8 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 0555f37be9..0c7a6b27a5 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 @@ -15,6 +15,9 @@ */ package androidx.media3.exoplayer.hls.playlist; +import static androidx.media3.common.util.Assertions.checkArgument; +import static androidx.media3.common.util.Assertions.checkNotNull; +import static androidx.media3.common.util.Assertions.checkState; import static java.lang.Math.max; import static java.lang.Math.min; import static java.lang.annotation.ElementType.TYPE_USE; @@ -22,6 +25,7 @@ import static java.lang.annotation.ElementType.TYPE_USE; import android.net.Uri; import androidx.annotation.IntDef; import androidx.annotation.Nullable; +import androidx.annotation.StringDef; import androidx.media3.common.C; import androidx.media3.common.DrmInitData; import androidx.media3.common.StreamKey; @@ -36,6 +40,7 @@ import java.lang.annotation.Target; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; /** Represents an HLS media playlist. */ @UnstableApi @@ -372,7 +377,7 @@ public final class HlsMediaPlaylist extends HlsPlaylist { /** * A rendition report for an alternative rendition defined in another media playlist. * - *

See RFC 8216, section 4.4.5.1.4. + *

See RFC 8216bis, section 4.4.5.1.4. */ public static final class RenditionReport { /** The URI of the media playlist of the reported rendition. */ @@ -401,6 +406,303 @@ public final class HlsMediaPlaylist extends HlsPlaylist { } } + /** + * An interstitial data range. + * + *

See RFC 8216bis, appendix D.2. + */ + public static final class Interstitial { + + /** + * The cue trigger type. One of {@link #CUE_TRIGGER_PRE}, {@link #CUE_TRIGGER_POST} or {@link + * #CUE_TRIGGER_ONCE}. + * + *

See RFC 8216bis, section 4.4.5.1. + */ + @Retention(RetentionPolicy.SOURCE) + @StringDef({CUE_TRIGGER_PRE, CUE_TRIGGER_POST, CUE_TRIGGER_ONCE}) + @Documented + @Target(TYPE_USE) + public @interface CueTriggerType {} + + /** + * Cue trigger type indicating to trigger the interstitial before playback of the primary asset. + */ + public static final String CUE_TRIGGER_PRE = "PRE"; + + /** + * Cue trigger type indicating to trigger the interstitial after playback of the primary asset. + */ + public static final String CUE_TRIGGER_POST = "POST"; + + /** Cue trigger type indicating to trigger the interstitial only once. */ + public static final String CUE_TRIGGER_ONCE = "ONCE"; + + /** + * The snap identifier. One of {@link #SNAP_TYPE_IN} or {@link #SNAP_TYPE_OUT}. + * + *

See RFC 8216bis, appendix D.2. + */ + @Retention(RetentionPolicy.SOURCE) + @StringDef({SNAP_TYPE_IN, SNAP_TYPE_OUT}) + @Documented + @Target(TYPE_USE) + public @interface SnapType {} + + /** + * Snap identifier indicating to locate the segment boundary closest to the scheduled resumption + * point of the interstitial. + */ + public static final String SNAP_TYPE_IN = "IN"; + + /** + * Snap identifier indicating to locate the segment boundary closest to the {@link + * Interstitial#startDateUnixUs}. + */ + public static final String SNAP_TYPE_OUT = "OUT"; + + /** + * The navigation restriction identifier. One of {@link #NAVIGATION_RESTRICTION_JUMP} or {@link + * #NAVIGATION_RESTRICTION_SKIP}. + * + *

See RFC 8216bis, appendix D.2. + */ + @Retention(RetentionPolicy.SOURCE) + @StringDef({NAVIGATION_RESTRICTION_SKIP, NAVIGATION_RESTRICTION_JUMP}) + @Documented + @Target(TYPE_USE) + public @interface NavigationRestriction {} + + /** + * Navigation restriction identifier indicating to prevent seeking or changing the playback + * speed during the interstitial being played. + */ + public static final String NAVIGATION_RESTRICTION_SKIP = "SKIP"; + + /** + * Navigation restriction identifier indicating to enforce playback of the interstitial if the + * user attempts to seek beyond the interstitial start position. + */ + public static final String NAVIGATION_RESTRICTION_JUMP = "JUMP"; + + /** The required ID. */ + public final String id; + + /** The asset URI. Required if {@link #assetListUri} is null. */ + @Nullable public final Uri assetUri; + + /** The asset list URI. Required if {@link #assetUri} is null. */ + @Nullable public final Uri assetListUri; + + /** The required start time, in microseconds. */ + public final long startDateUnixUs; + + /** The optional end time, in microseconds. {@link C#TIME_UNSET} if not present. */ + public final long endDateUnixUs; + + /** The optional duration, in microseconds. {@link C#TIME_UNSET} if not present. */ + public final long durationUs; + + /** The optional planned duration, in microseconds. {@link C#TIME_UNSET} if not present. */ + public final long plannedDurationUs; + + /** The trigger cue types. */ + public final List<@CueTriggerType String> cue; + + /** + * Whether the {@link #endDateUnixUs} of the interstitial is equal to the start {@link + * #startTimeUs} of the following interstitial. {@code false} if not present. + */ + public final boolean endOnNext; + + /** + * The offset from {@link #startTimeUs} indicating where in the primary asset to resume playback + * after completing playback of the interstitial. {@link C#TIME_UNSET} if not present. If not + * present, the value is considered to be the duration of the interstitial. + */ + public final long resumeOffsetUs; + + /** The playout limit indicating the limit of the playback time of the interstitial. */ + public final long playoutLimitUs; + + /** The snap types. */ + public final ImmutableList<@SnapType String> snapTypes; + + /** The navigation restrictions. */ + public final ImmutableList<@NavigationRestriction String> restrictions; + + /** The attributes defined by a client. For informational purpose only. */ + public final ImmutableList clientDefinedAttributes; + + /** Creates an instance. */ + public Interstitial( + String id, + @Nullable Uri assetUri, + @Nullable Uri assetListUri, + long startDateUnixUs, + long endDateUnixUs, + long durationUs, + long plannedDurationUs, + List<@CueTriggerType String> cue, + boolean endOnNext, + long resumeOffsetUs, + long playoutLimitUs, + List<@SnapType String> snapTypes, + List<@NavigationRestriction String> restrictions, + List clientDefinedAttributes) { + checkArgument( + (assetUri == null || assetListUri == null) && (assetUri != null || assetListUri != null)); + this.id = id; + this.assetUri = assetUri; + this.assetListUri = assetListUri; + this.startDateUnixUs = startDateUnixUs; + this.endDateUnixUs = endDateUnixUs; + this.durationUs = durationUs; + this.plannedDurationUs = plannedDurationUs; + this.cue = cue; + this.endOnNext = endOnNext; + this.resumeOffsetUs = resumeOffsetUs; + this.playoutLimitUs = playoutLimitUs; + this.snapTypes = ImmutableList.copyOf(snapTypes); + this.restrictions = ImmutableList.copyOf(restrictions); + this.clientDefinedAttributes = ImmutableList.copyOf(clientDefinedAttributes); + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Interstitial)) { + return false; + } + Interstitial that = (Interstitial) o; + return startDateUnixUs == that.startDateUnixUs + && endDateUnixUs == that.endDateUnixUs + && durationUs == that.durationUs + && plannedDurationUs == that.plannedDurationUs + && endOnNext == that.endOnNext + && resumeOffsetUs == that.resumeOffsetUs + && playoutLimitUs == that.playoutLimitUs + && Objects.equals(id, that.id) + && Objects.equals(assetUri, that.assetUri) + && Objects.equals(assetListUri, that.assetListUri) + && Objects.equals(cue, that.cue) + && Objects.equals(snapTypes, that.snapTypes) + && Objects.equals(restrictions, that.restrictions) + && Objects.equals(clientDefinedAttributes, that.clientDefinedAttributes); + } + + @Override + public int hashCode() { + return Objects.hash( + id, + assetUri, + assetListUri, + startDateUnixUs, + endDateUnixUs, + durationUs, + plannedDurationUs, + cue, + endOnNext, + resumeOffsetUs, + playoutLimitUs, + snapTypes, + restrictions, + clientDefinedAttributes); + } + } + + /** A client defined attribute. See RFC 8216bis, section 4.4.5.1. */ + public static class ClientDefinedAttribute { + + /** + * The type of the client defined attribute. One of {@link #TYPE_TEXT}, {@link #TYPE_HEX_TEXT} + * or {@link #TYPE_DOUBLE}. + */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({TYPE_DOUBLE, TYPE_HEX_TEXT, TYPE_TEXT}) + @Documented + @Target(TYPE_USE) + public @interface Type {} + + /** Type text. See RFC 8216bis, section 4.2, quoted-string. */ + public static final int TYPE_TEXT = 0; + + /** Type hex text. See RFC 8216bis, section 4.2, hexadecimal-sequence. */ + public static final int TYPE_HEX_TEXT = 1; + + /** Type double. See RFC 8216bis, section 4.2, decimal-floating-point. */ + public static final int TYPE_DOUBLE = 2; + + /** The name of the client defined attribute. */ + public final String name; + + /** The type of the client defined attribute. */ + public final int type; + + private final double doubleValue; + @Nullable private final String textValue; + + /** Creates an instance of type {@link #TYPE_DOUBLE}. */ + public ClientDefinedAttribute(String name, double value) { + this.name = name; + this.type = TYPE_DOUBLE; + this.doubleValue = value; + textValue = null; + } + + /** Creates an instance of type {@link #TYPE_TEXT} or {@link #TYPE_HEX_TEXT}. */ + public ClientDefinedAttribute(String name, String value, @Type int type) { + checkState(type != TYPE_HEX_TEXT || value.startsWith("0x") || value.startsWith("0X")); + this.name = name; + this.type = type; + this.textValue = value; + doubleValue = 0.0d; + } + + /** + * Returns the value if the attribute is of {@link #TYPE_DOUBLE}. + * + * @throws IllegalStateException if the attribute is not of type {@link #TYPE_TEXT} or {@link + * #TYPE_HEX_TEXT}. + */ + public double getDoubleValue() { + checkState(type == TYPE_DOUBLE); + return doubleValue; + } + + /** + * Returns the text value if the attribute is of {@link #TYPE_TEXT} or {@link #TYPE_HEX_TEXT}. + * + * @throws IllegalStateException if the attribute is not of type {@link #TYPE_DOUBLE}. + */ + public String getTextValue() { + checkState(type != TYPE_DOUBLE); + return checkNotNull(textValue); + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ClientDefinedAttribute)) { + return false; + } + ClientDefinedAttribute that = (ClientDefinedAttribute) o; + return type == that.type + && Double.compare(doubleValue, that.doubleValue) == 0 + && Objects.equals(name, that.name) + && Objects.equals(textValue, that.textValue); + } + + @Override + public int hashCode() { + return Objects.hash(name, type, doubleValue, textValue); + } + } + /** * Type of the playlist, as defined by #EXT-X-PLAYLIST-TYPE. One of {@link * #PLAYLIST_TYPE_UNKNOWN}, {@link #PLAYLIST_TYPE_VOD} or {@link #PLAYLIST_TYPE_EVENT}. @@ -497,6 +799,12 @@ public final class HlsMediaPlaylist extends HlsPlaylist { /** The attributes of the #EXT-X-SERVER-CONTROL header. */ public final ServerControl serverControl; + /** + * The interstitials declared as {@code #EXT-X-DATERANGE} with {@code + * CLASS="com.apple.hls.interstitial"} + */ + public final ImmutableList interstitials; + /** * Constructs an instance. * @@ -520,6 +828,7 @@ public final class HlsMediaPlaylist extends HlsPlaylist { * @param trailingParts See {@link #trailingParts}. * @param serverControl See {@link #serverControl} * @param renditionReports See {@link #renditionReports}. + * @param interstitials See {@link #interstitials}. */ public HlsMediaPlaylist( @PlaylistType int playlistType, @@ -541,7 +850,8 @@ public final class HlsMediaPlaylist extends HlsPlaylist { List segments, List trailingParts, ServerControl serverControl, - Map renditionReports) { + Map renditionReports, + List interstitials) { super(baseUri, tags, hasIndependentSegments); this.playlistType = playlistType; this.startTimeUs = startTimeUs; @@ -558,6 +868,7 @@ public final class HlsMediaPlaylist extends HlsPlaylist { this.segments = ImmutableList.copyOf(segments); this.trailingParts = ImmutableList.copyOf(trailingParts); this.renditionReports = ImmutableMap.copyOf(renditionReports); + this.interstitials = ImmutableList.copyOf(interstitials); if (!trailingParts.isEmpty()) { Part lastPart = Iterables.getLast(trailingParts); durationUs = lastPart.relativeStartTimeUs + lastPart.durationUs; @@ -567,7 +878,7 @@ public final class HlsMediaPlaylist extends HlsPlaylist { } else { durationUs = 0; } - // From RFC 8216, section 4.4.2.2: If startOffsetUs is negative, it indicates the offset from + // From RFC 8216bis, section 4.4.2.2: If startOffsetUs is negative, it indicates the offset from // the end of the playlist. If the absolute value exceeds the duration of the playlist, it // indicates the beginning (if negative) or the end (if positive) of the playlist. this.startOffsetUs = @@ -644,7 +955,8 @@ public final class HlsMediaPlaylist extends HlsPlaylist { segments, trailingParts, serverControl, - renditionReports); + renditionReports, + interstitials); } /** @@ -675,6 +987,7 @@ public final class HlsMediaPlaylist extends HlsPlaylist { segments, trailingParts, serverControl, - renditionReports); + renditionReports, + interstitials); } } 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 8dc60984f2..861e1f326f 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 @@ -18,6 +18,11 @@ package androidx.media3.exoplayer.hls.playlist; import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkState; import static androidx.media3.common.util.Util.castNonNull; +import static androidx.media3.common.util.Util.msToUs; +import static androidx.media3.common.util.Util.parseXsDateTime; +import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.CUE_TRIGGER_ONCE; +import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.CUE_TRIGGER_POST; +import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.CUE_TRIGGER_PRE; import android.net.Uri; import android.text.TextUtils; @@ -37,6 +42,7 @@ import androidx.media3.common.util.UriUtil; import androidx.media3.common.util.Util; import androidx.media3.exoplayer.hls.HlsTrackMetadataEntry; import androidx.media3.exoplayer.hls.HlsTrackMetadataEntry.VariantInfo; +import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial; import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Part; import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.RenditionReport; import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Segment; @@ -44,6 +50,7 @@ import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist.Rendition; 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; @@ -105,6 +112,7 @@ public final class HlsPlaylistParser implements ParsingLoadable.Parser renditionReports = new ArrayList<>(); List tags = new ArrayList<>(); + List interstitials = new ArrayList<>(); long segmentDurationUs = 0; String segmentTitle = ""; @@ -848,7 +877,7 @@ public final class HlsPlaylistParser implements ParsingLoadable.Parser cue = new ArrayList<>(); + String cueString = parseOptionalStringAttr(line, REGEX_CUE, variableDefinitions); + if (cueString != null) { + String[] identifiers = Util.split(/* value= */ cueString, /* regex= */ ","); + for (String identifier : identifiers) { + identifier = identifier.trim(); + switch (identifier) { + case CUE_TRIGGER_ONCE: + case CUE_TRIGGER_POST: + case CUE_TRIGGER_PRE: + cue.add(identifier); + break; + default: + break; // ignore for forward compatibility + } + } + } + double durationSec = parseOptionalDoubleAttr(line, REGEX_ATTR_DURATION, -1.0d); + long durationUs = C.TIME_UNSET; + if (durationSec >= 0) { + durationUs = (long) (durationSec * C.MICROS_PER_SECOND); + } + double plannedDurationSec = parseOptionalDoubleAttr(line, REGEX_PLANNED_DURATION, -1.0d); + long plannedDurationUs = C.TIME_UNSET; + if (plannedDurationSec >= 0) { + plannedDurationUs = (long) (plannedDurationSec * C.MICROS_PER_SECOND); + } + boolean endOnNext = parseOptionalBooleanAttribute(line, REGEX_END_ON_NEXT, false); + double resumeOffsetUsDouble = + parseOptionalDoubleAttr(line, REGEX_RESUME_OFFSET, Double.MIN_VALUE); + long resumeOffsetUs = C.TIME_UNSET; + if (resumeOffsetUsDouble != Double.MIN_VALUE) { + resumeOffsetUs = (long) (resumeOffsetUsDouble * C.MICROS_PER_SECOND); + } + double playoutLimitSec = parseOptionalDoubleAttr(line, REGEX_PLAYOUT_LIMIT, -1.0d); + long playoutLimitUs = C.TIME_UNSET; + if (playoutLimitSec >= 0) { + playoutLimitUs = (long) (playoutLimitSec * C.MICROS_PER_SECOND); + } + List<@Interstitial.SnapType String> snapTypes = new ArrayList<>(); + String snapTypesString = parseOptionalStringAttr(line, REGEX_SNAP, variableDefinitions); + if (snapTypesString != null) { + String[] snapTypesSplit = Util.split(snapTypesString, ","); + for (String snapType : snapTypesSplit) { + snapType = snapType.trim(); + switch (snapType) { + case Interstitial.SNAP_TYPE_IN: + case Interstitial.SNAP_TYPE_OUT: + snapTypes.add(snapType); + break; + default: + break; // ignore for forward compatibility + } + } + } + List<@Interstitial.NavigationRestriction String> restrictions = new ArrayList<>(); + String restrictionsString = + parseOptionalStringAttr(line, REGEX_RESTRICT, variableDefinitions); + if (restrictionsString != null) { + String[] restrictionsSplit = Util.split(restrictionsString, ","); + for (String restriction : restrictionsSplit) { + restriction = restriction.trim(); + switch (restriction) { + case Interstitial.NAVIGATION_RESTRICTION_JUMP: + case Interstitial.NAVIGATION_RESTRICTION_SKIP: + restrictions.add(restriction); + break; + default: + break; // ignore for forward compatibility + } + } + } + + ImmutableList.Builder clientDefinedAttributes = + new ImmutableList.Builder<>(); + String attributes = line.substring("#EXT-X-DATERANGE:".length()); + Matcher matcher = REGEX_CLIENT_DEFINED_ATTRIBUTE_PREFIX.matcher(attributes); + while (matcher.find()) { + String attributePrefix = matcher.group(); + switch (attributePrefix) { + case "X-ASSET-URI=": // fall through + case "X-ASSET-LIST=": // fall through + case "X-RESUME-OFFSET=": // fall through + case "X-PLAYOUT-LIMIT=": // fall through + case "X-SNAP=": // fall through + case "X-RESTRICT=": // fall through + // ignore interstitial attributes + break; + default: + clientDefinedAttributes.add( + parseClientDefinedAttribute( + attributes, + attributePrefix.substring(0, attributePrefix.length() - 1), + variableDefinitions)); + break; + } + } + interstitials.add( + new Interstitial( + id, + assetUri, + assetListUri, + startDateUnixUs, + endDateUnixUs, + durationUs, + plannedDurationUs, + cue, + endOnNext, + resumeOffsetUs, + playoutLimitUs, + snapTypes, + restrictions, + clientDefinedAttributes.build())); } else if (!line.startsWith("#")) { @Nullable String segmentEncryptionIV = @@ -1062,7 +1234,8 @@ public final class HlsPlaylistParser implements ParsingLoadable.Parser variableDefinitions) + throws ParserException { + String prefix = clientAttribute + "="; + int index = attributes.indexOf(prefix); + int valueBeginIndex = index + prefix.length(); + int valueBeginMaxLength = attributes.length() == valueBeginIndex + 1 ? 1 : 2; + String valueBegin = + attributes.substring(valueBeginIndex, valueBeginIndex + valueBeginMaxLength); + if (valueBegin.startsWith("\"")) { + // a quoted string value + Pattern pattern = Pattern.compile(clientAttribute + "=\"(.+?)\""); + String value = parseStringAttr(attributes, pattern, variableDefinitions); + return new HlsMediaPlaylist.ClientDefinedAttribute( + clientAttribute, value, HlsMediaPlaylist.ClientDefinedAttribute.TYPE_TEXT); + } else if (valueBegin.equals("0x") || valueBegin.equals("0X")) { + // a hexadecimal sequence value + Pattern pattern = Pattern.compile(clientAttribute + "=(0[xX][A-F0-9]+)"); + String value = parseStringAttr(attributes, pattern, variableDefinitions); + return new HlsMediaPlaylist.ClientDefinedAttribute( + clientAttribute, value, HlsMediaPlaylist.ClientDefinedAttribute.TYPE_HEX_TEXT); + } else { + // a decimal-floating-point value + Pattern pattern = Pattern.compile(clientAttribute + "=([\\d\\.]+)\\b"); + return new HlsMediaPlaylist.ClientDefinedAttribute( + clientAttribute, parseDoubleAttr(attributes, pattern)); + } + } + private static String replaceVariableReferences( String string, Map variableDefinitions) { Matcher matcher = REGEX_VARIABLE_REFERENCE.matcher(string); 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 ddfbb908a3..af603704fa 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 @@ -15,7 +15,16 @@ */ package androidx.media3.exoplayer.hls.playlist; +import static androidx.media3.common.util.Util.msToUs; +import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.CUE_TRIGGER_ONCE; +import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.CUE_TRIGGER_POST; +import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.CUE_TRIGGER_PRE; +import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.NAVIGATION_RESTRICTION_JUMP; +import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.NAVIGATION_RESTRICTION_SKIP; +import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.SNAP_TYPE_IN; +import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.SNAP_TYPE_OUT; import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.fail; import android.net.Uri; @@ -24,13 +33,18 @@ import androidx.annotation.Nullable; import androidx.media3.common.C; import androidx.media3.common.ParserException; import androidx.media3.common.util.Util; +import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial; import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Segment; import androidx.media3.extractor.mp4.PsshAtomUtil; +import androidx.media3.test.utils.TestUtil; +import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.text.ParseException; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -1104,6 +1118,405 @@ public class HlsMediaPlaylistParserTest { assertThat(report0.lastPartIndex).isEqualTo(3); } + @Test + public void parseMediaPlaylist_withInterstitialDateRanges() throws IOException, ParseException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "CLASS=\"com.apple.hls.interstitial\"," + + "ID=\"ad1\"," + + "START-DATE=\"2020-01-02T21:55:44.000Z\"," + + "END-DATE=\"2020-01-02T21:55:59.000Z\"," + + "DURATION=15.0," + + "PLANNED-DURATION=15.13," + + "CUE=\" PRE \"," + + "END-ON-NEXT=YES, " + + "X-ASSET-URI=\"http://example.com/ad1.m3u8\"," + + "X-RESUME-OFFSET=-1.1, " + + "X-PLAYOUT-LIMIT=15.112, " + + "X-SNAP=\" IN , OUT \", " + + "X-RESTRICT=\"SKIP , JUMP\"," + + "X-GOOGLE-TEST-TEXT=\"Value at the end\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "X-GOOGLE-TEST-HEX=0XAB10A," + + "ID=\"ad2\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T22:55:44.000Z\"," + + "END-DATE=\"2020-01-02T22:55:49.120Z\"," + + "DURATION=5.12," + + "PLANNED-DURATION=5.11," + + "CUE=\" POST, ONCE\"," + + "X-GOOGLE-TEST-DOUBLE1=12.123," + + "X-ASSET-LIST=\"http://example.com/ad2-assets.json\"" + + "X-GOOGLE-TEST-DOUBLE2=1\n"; + long playlistStartTimeUs = msToUs(Util.parseXsDateTime("2020-01-02T21:55:40.000Z")); + Interstitial interstitial1 = + new Interstitial( + /* id= */ "ad1", + /* assetUri= */ Uri.parse("http://example.com/ad1.m3u8"), + /* assetListUri= */ null, + /* startDateUnixUs= */ playlistStartTimeUs + 4_000_000L, + /* endDateUnixUs= */ playlistStartTimeUs + 4_000_000L + 15_000_000L, + /* durationUs= */ 15_000_000L, + /* plannedDurationUs= */ 15_130_000L, + /* cue= */ ImmutableList.of(CUE_TRIGGER_PRE), + /* endOnNext= */ true, + /* resumeOffsetUs= */ -1_100_000L, + /* playoutLimitUs= */ 15_112_000L, + ImmutableList.of(SNAP_TYPE_IN, SNAP_TYPE_OUT), + ImmutableList.of(NAVIGATION_RESTRICTION_SKIP, NAVIGATION_RESTRICTION_JUMP), + /* clientDefinedAttributes= */ ImmutableList.of( + new HlsMediaPlaylist.ClientDefinedAttribute( + "X-GOOGLE-TEST-TEXT", + "Value at the end", + HlsMediaPlaylist.ClientDefinedAttribute.TYPE_TEXT))); + Interstitial interstitial2 = + new Interstitial( + /* id= */ "ad2", + /* assetUri= */ null, + /* assetListUri= */ Uri.parse("http://example.com/ad2-assets.json"), + /* startDateUnixUs= */ playlistStartTimeUs + 4_000_000L + (60L * 60L * 1_000_000L), + /* endDateUnixUs= */ playlistStartTimeUs + + 4_000_000L + + 5_120_000L + + (60L * 60L * 1_000_000L), + /* durationUs= */ 5_120_000L, + /* plannedDurationUs= */ 5_110_000L, + ImmutableList.of(CUE_TRIGGER_POST, CUE_TRIGGER_ONCE), + /* endOnNext= */ false, + /* resumeOffsetUs= */ C.TIME_UNSET, + /* playoutLimitUs= */ C.TIME_UNSET, + /* snapTypes= */ ImmutableList.of(), + /* restrictions= */ ImmutableList.of(), + /* clientDefinedAttributes= */ ImmutableList.of( + 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))); + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + + assertThat(playlist.interstitials).containsExactly(interstitial1, interstitial2).inOrder(); + } + + @Test + public void parseMediaPlaylist_withInterstitialsVodWithAssetUriOnly_correctlyParseAllAttributes() + throws IOException { + Uri playlistUri = Uri.parse("http://example.com/interstitials_vod_asset_uri.m3u8"); + InputStream inputStream = + TestUtil.getInputStream( + ApplicationProvider.getApplicationContext(), + "media/hls/interstitials/interstitials_vod_asset_uri.m3u8"); + Interstitial interstitial1 = + new Interstitial( + "preroll", + /* assetUri= */ Uri.parse("https://example.com/0.m3u8?parse=1"), + /* assetListUri= */ null, + /* startDateUnixUs= */ msToUs(Util.parseXsDateTime("2024-11-11T04:35:19.353-08:00")), + /* endDateUnixUs= */ C.TIME_UNSET, + /* durationUs= */ C.TIME_UNSET, + /* plannedDurationUs= */ C.TIME_UNSET, + /* cue= */ ImmutableList.of("PRE"), + /* endOnNext= */ false, + /* resumeOffsetUs= */ 0L, + /* playoutLimitUs= */ C.TIME_UNSET, + /* snapTypes= */ ImmutableList.of(), + /* restrictions= */ ImmutableList.of("JUMP", "SKIP"), + /* clientDefinedAttributes= */ ImmutableList.of()); + Interstitial interstitial2 = + new Interstitial( + "midroll-1", + /* assetUri= */ Uri.parse("https://example.com/1.m3u8?parse=1"), + /* assetListUri= */ null, + /* startDateUnixUs= */ msToUs(Util.parseXsDateTime("2024-11-11T04:35:34.353-08:00")), + /* endDateUnixUs= */ C.TIME_UNSET, + /* durationUs= */ C.TIME_UNSET, + /* plannedDurationUs= */ C.TIME_UNSET, + /* cue= */ ImmutableList.of(), + /* endOnNext= */ false, + /* resumeOffsetUs= */ 0L, + /* playoutLimitUs= */ C.TIME_UNSET, + /* snapTypes= */ ImmutableList.of(), + /* restrictions= */ ImmutableList.of("JUMP", "SKIP"), + /* clientDefinedAttributes= */ ImmutableList.of()); + Interstitial interstitial3 = + new Interstitial( + "postroll", + /* assetUri= */ Uri.parse("https://example.com/2.m3u8?parse=1"), + /* assetListUri= */ null, + /* startDateUnixUs= */ msToUs(Util.parseXsDateTime("2024-11-11T04:36:31.353-08:00")), + /* endDateUnixUs= */ C.TIME_UNSET, + /* durationUs= */ C.TIME_UNSET, + /* plannedDurationUs= */ C.TIME_UNSET, + /* cue= */ ImmutableList.of("POST"), + /* endOnNext= */ false, + /* resumeOffsetUs= */ 0L, + /* playoutLimitUs= */ C.TIME_UNSET, + /* snapTypes= */ ImmutableList.of(), + /* restrictions= */ ImmutableList.of( + NAVIGATION_RESTRICTION_JUMP, NAVIGATION_RESTRICTION_SKIP), + /* clientDefinedAttributes= */ ImmutableList.of()); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + + assertThat(playlist.interstitials) + .containsExactly(interstitial1, interstitial2, interstitial3) + .inOrder(); + assertThat(playlist.startTimeUs) + .isEqualTo(msToUs(Util.parseXsDateTime("2024-11-11T04:35:19.353-08:00"))); + } + + @Test + public void parseMediaPlaylist_withInterstitialWithoutId_throwsParserException() { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "CLASS=\"com.apple.hls.interstitial\"," + + "X-ASSET-URI=\"http://example.com/ad1.m3u8\"," + + "START-DATE=\"2020-01-02T21:55:44.000Z\"\n"; + HlsPlaylistParser hlsPlaylistParser = new HlsPlaylistParser(); + + assertThrows( + ParserException.class, + () -> + hlsPlaylistParser.parse( + playlistUri, new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)))); + } + + @Test + public void parseMediaPlaylist_withDateRangeWithUnknownClass_dateRangeIgnored() + throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad2\"," + + "CLASS=\"some.other.class\"," + + "X-ASSET-URI=\"http://example.com/ad1.m3u8\"," + + "START-DATE=\"2020-01-02T21:55:44.000Z\"\n"; + HlsPlaylistParser hlsPlaylistParser = new HlsPlaylistParser(); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) + hlsPlaylistParser.parse( + playlistUri, new ByteArrayInputStream(Util.getUtf8Bytes(playlistString))); + + assertThat(playlist.interstitials).isEmpty(); + } + + @Test + public void parseMediaPlaylist_withDateRangeWithMissingClass_dateRangeIgnored() + throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad2\"," + + "X-ASSET-URI=\"http://example.com/ad1.m3u8\"," + + "START-DATE=\"2020-01-02T21:55:44.000Z\"\n"; + HlsPlaylistParser hlsPlaylistParser = new HlsPlaylistParser(); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) + hlsPlaylistParser.parse( + playlistUri, new ByteArrayInputStream(Util.getUtf8Bytes(playlistString))); + + assertThat(playlist.interstitials).isEmpty(); + } + + @Test + public void parseMediaPlaylist_withInterstitialWithoutStartDate_throwsParserException() { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad2\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "X-ASSET-LIST=\"http://example.com/ad2-assets.json\"\n"; + HlsPlaylistParser hlsPlaylistParser = new HlsPlaylistParser(); + + assertThrows( + ParserException.class, + () -> + hlsPlaylistParser.parse( + playlistUri, new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)))); + } + + @Test + public void parseMediaPlaylist_withInterstitialWithAssetUriAndList_throwsParserException() { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad2\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:44.000Z\"," + + "X-ASSET-URI=\"http://example.com/media.m3u8\"," + + "X-ASSET-LIST=\"http://example.com/ad2-assets.json\"\n"; + HlsPlaylistParser hlsPlaylistParser = new HlsPlaylistParser(); + + assertThrows( + ParserException.class, + () -> + hlsPlaylistParser.parse( + playlistUri, new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)))); + } + + @Test + public void + parseMediaPlaylist_withInterstitialWithNeitherAssetUriNorAssetList_throwsParserException() { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad2\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:44.000Z\"\n"; + HlsPlaylistParser hlsPlaylistParser = new HlsPlaylistParser(); + + assertThrows( + ParserException.class, + () -> + hlsPlaylistParser.parse( + playlistUri, new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)))); + } + + @Test + public void + parseMediaPlaylist_withInterstitialWithUnknownRestrictions_ignoredForForwardCompatibility() + throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad2\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "X-RESTRICT=\"SKIP , FLY , ROW , JUMP\"," + + "X-ASSET-URI=\"http://example.com/media.m3u8\"," + + "START-DATE=\"2020-01-02T21:55:44.000Z\"\n"; + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) + new HlsPlaylistParser() + .parse(playlistUri, new ByteArrayInputStream(Util.getUtf8Bytes(playlistString))); + + assertThat(playlist.interstitials.get(0).restrictions) + .containsExactly(NAVIGATION_RESTRICTION_SKIP, NAVIGATION_RESTRICTION_JUMP) + .inOrder(); + } + + @Test + public void + parseMediaPlaylist_withInterstitialWithUnknownCueIdentifier_ignoredForForwardCompatibility() + throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad2\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "X-CUE=\"ALONE , PRE , INBETWEEN , BEYOND\"," + + "X-ASSET-URI=\"http://example.com/media.m3u8\"," + + "START-DATE=\"2020-01-02T21:55:44.000Z\"\n"; + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) + new HlsPlaylistParser() + .parse(playlistUri, new ByteArrayInputStream(Util.getUtf8Bytes(playlistString))); + + assertThat(playlist.interstitials.get(0).cue).containsExactly(CUE_TRIGGER_PRE); + } + + @Test + public void + parseMediaPlaylist_withInterstitialWithUnknownSnapTypes_ignoredForForwardCompatibility() + throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad2\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "X-SNAP=\" FOR , OUT , AT\", " + + "X-ASSET-URI=\"http://example.com/media.m3u8\"," + + "START-DATE=\"2020-01-02T21:55:44.000Z\"\n"; + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) + new HlsPlaylistParser() + .parse(playlistUri, new ByteArrayInputStream(Util.getUtf8Bytes(playlistString))); + + assertThat(playlist.interstitials.get(0).snapTypes).containsExactly(SNAP_TYPE_OUT); + } + @Test public void multipleExtXKeysForSingleSegment() throws Exception { Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); diff --git a/libraries/test_data/src/test/assets/media/hls/interstitials/interstitials_vod_asset_uri.m3u8 b/libraries/test_data/src/test/assets/media/hls/interstitials/interstitials_vod_asset_uri.m3u8 new file mode 100644 index 0000000000..e27e967fec --- /dev/null +++ b/libraries/test_data/src/test/assets/media/hls/interstitials/interstitials_vod_asset_uri.m3u8 @@ -0,0 +1,47 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:6 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-INDEPENDENT-SEGMENTS +#EXT-X-PLAYLIST-TYPE:VOD +#EXT-X-BITRATE:4612 +#EXT-X-PROGRAM-DATE-TIME:2024-11-11T04:35:19.353-08:00 +#EXTINF:6, +https://example.com/fileSequence0.ts +#EXT-X-BITRATE:4609 +#EXTINF:6, +https://example.com/fileSequence1.ts +#EXT-X-BITRATE:4609 +#EXTINF:6, +https://example.com/fileSequence2.ts +#EXT-X-BITRATE:4611 +#EXTINF:6, +https://example.com/fileSequence3.ts +#EXT-X-BITRATE:4611 +#EXTINF:6, +https://example.com/fileSequence4.ts +#EXT-X-BITRATE:4609 +#EXTINF:6, +https://example.com/fileSequence5.ts +#EXT-X-BITRATE:4610 +#EXTINF:6, +https://example.com/fileSequence6.ts +#EXT-X-BITRATE:4610 +#EXTINF:6, +https://example.com/fileSequence7.ts +#EXT-X-BITRATE:4611 +#EXTINF:6, +https://example.com/fileSequence8.ts +#EXT-X-BITRATE:4609 +#EXTINF:6, +https://example.com/fileSequence9.ts +#EXT-X-BITRATE:4609 +#EXTINF:6, +https://example.com/fileSequence10.ts +#EXT-X-BITRATE:4611 +#EXTINF:6, +https://example.com/fileSequence11.ts +#EXT-X-DATERANGE:ID="preroll",START-DATE="2024-11-11T04:35:19.353-08:00",CLASS="com.apple.hls.interstitial",CUE="PRE",X-ASSET-URI="https://example.com/0.m3u8?parse=1",X-RESUME-OFFSET=0,X-RESTRICT="JUMP,SKIP" +#EXT-X-DATERANGE:ID="midroll-1",START-DATE="2024-11-11T04:35:34.353-08:00",CLASS="com.apple.hls.interstitial",X-ASSET-URI="https://example.com/1.m3u8?parse=1",X-RESUME-OFFSET=0,X-RESTRICT="JUMP,SKIP" +#EXT-X-DATERANGE:ID="postroll",START-DATE="2024-11-11T04:36:31.353-08:00",CLASS="com.apple.hls.interstitial",CUE="POST",X-ASSET-URI="https://example.com/2.m3u8?parse=1",X-RESUME-OFFSET=0,X-RESTRICT="JUMP,SKIP" +#EXT-X-ENDLIST