diff --git a/src/main/java/com/blazemeter/jmeter/videostreaming/hls/Playlist.java b/src/main/java/com/blazemeter/jmeter/videostreaming/hls/Playlist.java index 84be2b2..c945e28 100755 --- a/src/main/java/com/blazemeter/jmeter/videostreaming/hls/Playlist.java +++ b/src/main/java/com/blazemeter/jmeter/videostreaming/hls/Playlist.java @@ -17,6 +17,7 @@ import com.comcast.viper.hlsparserj.tags.master.Media; import com.comcast.viper.hlsparserj.tags.master.StreamInf; import com.comcast.viper.hlsparserj.tags.media.ByteRange; +import com.comcast.viper.hlsparserj.tags.media.ExtInf; import com.comcast.viper.hlsparserj.tags.media.ExtMap; import com.comcast.viper.hlsparserj.tags.media.MediaSequence; import com.comcast.viper.hlsparserj.tags.media.PlaylistType; @@ -136,11 +137,34 @@ public List getMediaSegments() { AtomicInteger sequenceNumber = new AtomicInteger(sequence); if (!hasByteRange()) { - return mediaPlaylist.getSegments().stream() - .map(s -> new MediaSegment(sequenceNumber.getAndIncrement(), - uri.resolve(s.getURI()), Duration.ofMillis(Math.round(s.getDuration() * 1000)))) - .collect(Collectors.toList()); + // hlsparserj assigns the segment URI to the tag line immediately above it. When + // #EXT-X-PROGRAM-DATE-TIME (or other tags) sit between #EXTINF and the media URI + // line, ExtInf.getURI() is null. Playlists with PDT before #EXTINF parse correctly. + List segments = mediaPlaylist.getSegments(); + List fallbackUris = requiresSegmentUriFallback(segments) + ? extractSegmentUrisFromBody(body) : null; + List mediaSegments = new ArrayList<>(segments.size()); + for (int i = 0; i < segments.size(); i++) { + ExtInf segment = segments.get(i); + String segmentUri = segment.getURI(); + if (segmentUri == null && fallbackUris != null) { + if (i >= fallbackUris.size()) { + throw new IllegalStateException( + "Missing URI for segment at sequence " + sequenceNumber.get()); + } + segmentUri = fallbackUris.get(i); + } + if (segmentUri == null) { + throw new IllegalStateException( + "Missing URI for segment at sequence " + sequenceNumber.get()); + } + mediaSegments.add(new MediaSegment(sequenceNumber.getAndIncrement(), + uri.resolve(segmentUri), Duration.ofMillis(Math.round(segment.getDuration() * 1000)))); + } + return mediaSegments; } else { + // Byte-range playlists rely on hlsparserj associating URIs with EXT-X-BYTERANGE. + // Same PDT-between-EXTINF-and-URI ordering is not covered here List mediaSegments = mediaPlaylist.getSegments().stream() .map(s -> new MediaSegment(sequenceNumber.getAndIncrement(), Duration.ofMillis(Math.round(s.getDuration() * 1000)))).collect(Collectors.toList()); @@ -156,6 +180,30 @@ public List getMediaSegments() { } } + static boolean requiresSegmentUriFallback(List segments) { + return segments.stream().anyMatch(s -> s.getURI() == null); + } + + static List extractSegmentUrisFromBody(String body) { + List uris = new ArrayList<>(); + boolean afterExtInf = false; + for (String line : body.split("\n")) { + String trimmed = line.replace("\r", "").trim(); + if (trimmed.isEmpty()) { + continue; + } + if (trimmed.startsWith("#EXTINF")) { + afterExtInf = true; + } else if (trimmed.startsWith("#")) { + continue; + } else if (afterExtInf) { + uris.add(trimmed); + afterExtInf = false; + } + } + return uris; + } + public long getTargetDurationSeconds() { MediaPlaylist media = (MediaPlaylist) playlist; TargetDuration td = media.getTargetDuration(); diff --git a/src/test/java/com/blazemeter/jmeter/videostreaming/hls/HlsSamplerTest.java b/src/test/java/com/blazemeter/jmeter/videostreaming/hls/HlsSamplerTest.java index 3d69b1f..0e56712 100644 --- a/src/test/java/com/blazemeter/jmeter/videostreaming/hls/HlsSamplerTest.java +++ b/src/test/java/com/blazemeter/jmeter/videostreaming/hls/HlsSamplerTest.java @@ -38,6 +38,9 @@ public class HlsSamplerTest extends VideoStreamingSamplerTest { private static final String SIMPLE_MEDIA_PLAYLIST_NAME = "simpleMediaPlaylist.m3u8"; + private static final String MEDIA_PLAYLIST_PDT_BETWEEN_EXTINF_AND_URI_NAME = + "mediaPlaylistPdtBetweenExtInfAndUri.m3u8"; + private static final double PDT_BETWEEN_EXTINF_AND_URI_SEGMENT_DURATION = 4.0; private static final URI MASTER_URI = URI.create(BASE_URI + "/master.m3u8"); protected static final String MEDIA_TYPE_NAME = "media"; protected static final double MEDIA_SEGMENT_DURATION = 5.0; @@ -95,6 +98,18 @@ public void shouldDownloadSegmentWhenUriIsFromMediaPlaylist() throws Exception { buildMediaSegmentSampleResult(1)); } + @Test + public void shouldDownloadSegmentWhenPdtIsBetweenExtInfAndUri() throws Exception { + String mediaPlaylist = getResource(MEDIA_PLAYLIST_PDT_BETWEEN_EXTINF_AND_URI_NAME); + setupUriSamplerPlaylist(MASTER_URI, mediaPlaylist); + setPlaySeconds(PDT_BETWEEN_EXTINF_AND_URI_SEGMENT_DURATION); + sampler.sample(); + verifySampleResults( + buildBaseSampleResult(MEDIA_PLAYLIST_SAMPLE_NAME, MASTER_URI, mediaPlaylist), + buildMediaSegmentSampleResult(MASTER_URI.resolve("segment_001.ts"), + PDT_BETWEEN_EXTINF_AND_URI_SEGMENT_DURATION)); + } + private void setupUriSamplerPlaylist(URI uri, String... playlists) { HTTPSampleResult[] rest = new HTTPSampleResult[playlists.length - 1]; for (int i = 1; i < playlists.length; i++) { @@ -109,6 +124,12 @@ private HTTPSampleResult buildMediaSegmentSampleResult(int sequenceNumber) { MEDIA_SEGMENT_DURATION); } + private HTTPSampleResult buildMediaSegmentSampleResult(URI segmentUri, double segmentDuration) { + HTTPSampleResult result = buildSampleResult(segmentUri, SEGMENT_CONTENT_TYPE, ""); + result.setSampleLabel(buildSegmentName(MEDIA_TYPE_NAME)); + return addDurationHeader(result, segmentDuration); + } + private HTTPSampleResult buildExpectedSegmentSampleResult(int sequenceNumber, String segmentType, double segmentDuration) { HTTPSampleResult result = buildBaseSegmentSampleResult(segmentType, sequenceNumber); diff --git a/src/test/java/com/blazemeter/jmeter/videostreaming/hls/PlaylistTest.java b/src/test/java/com/blazemeter/jmeter/videostreaming/hls/PlaylistTest.java new file mode 100644 index 0000000..533105c --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/videostreaming/hls/PlaylistTest.java @@ -0,0 +1,80 @@ +package com.blazemeter.jmeter.videostreaming.hls; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.blazemeter.jmeter.videostreaming.core.MediaSegment; +import com.blazemeter.jmeter.videostreaming.core.exception.PlaylistParsingException; +import com.comcast.viper.hlsparserj.MediaPlaylist; +import com.comcast.viper.hlsparserj.tags.media.ExtInf; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import org.apache.commons.io.IOUtils; +import org.junit.Test; + +public class PlaylistTest { + + private static final URI MEDIA_PLAYLIST_URI = URI.create("http://test/media/variant.m3u8"); + + @Test + public void shouldResolveSegmentUrisWhenPdtIsBetweenExtInfAndUri() throws Exception { + Playlist playlist = parseResource("mediaPlaylistPdtBetweenExtInfAndUri.m3u8"); + List parsedSegments = ((MediaPlaylist) playlist.getPlaylist()).getSegments(); + + assertTrue(Playlist.requiresSegmentUriFallback(parsedSegments)); + + List segments = playlist.getMediaSegments(); + + assertEquals(3, segments.size()); + assertEquals(MEDIA_PLAYLIST_URI.resolve("segment_001.ts"), segments.get(0).getUri()); + assertEquals(MEDIA_PLAYLIST_URI.resolve("segment_002.ts"), segments.get(1).getUri()); + assertEquals(MEDIA_PLAYLIST_URI.resolve("segment_003.ts"), segments.get(2).getUri()); + assertEquals(17456L, segments.get(0).getSequenceNumber()); + assertEquals(17457L, segments.get(1).getSequenceNumber()); + assertEquals(17458L, segments.get(2).getSequenceNumber()); + } + + @Test + public void shouldKeepParserSegmentUrisWhenPdtIsBeforeExtInf() throws Exception { + Playlist playlist = parseResource("mediaPlaylistPdtBeforeExtInf.m3u8"); + List parsedSegments = ((MediaPlaylist) playlist.getPlaylist()).getSegments(); + + assertFalse(Playlist.requiresSegmentUriFallback(parsedSegments)); + + List segments = playlist.getMediaSegments(); + + assertEquals(2, segments.size()); + assertEquals(MEDIA_PLAYLIST_URI.resolve("segment_001.ts"), segments.get(0).getUri()); + assertEquals(MEDIA_PLAYLIST_URI.resolve("segment_002.ts"), segments.get(1).getUri()); + } + + @Test + public void shouldUseParserSegmentUrisWithoutFallbackForStandardPlaylist() throws Exception { + Playlist playlist = parseResource("simpleMediaPlaylist.m3u8"); + List parsedSegments = ((MediaPlaylist) playlist.getPlaylist()).getSegments(); + + assertFalse(Playlist.requiresSegmentUriFallback(parsedSegments)); + + List segments = playlist.getMediaSegments(); + + assertEquals(3, segments.size()); + assertEquals(MEDIA_PLAYLIST_URI.resolve("/media/001.ts"), segments.get(0).getUri()); + assertEquals(MEDIA_PLAYLIST_URI.resolve("/media/002.ts"), segments.get(1).getUri()); + assertEquals(MEDIA_PLAYLIST_URI.resolve("/media/003.ts"), segments.get(2).getUri()); + } + + private static Playlist parseResource(String resourceName) + throws IOException, PlaylistParsingException { + String path = "com/blazemeter/jmeter/videostreaming/hls/" + resourceName; + try (InputStream in = PlaylistTest.class.getClassLoader().getResourceAsStream(path)) { + String body = IOUtils.toString(in, StandardCharsets.UTF_8); + return Playlist.fromUriAndBody(MEDIA_PLAYLIST_URI, body, Instant.now()); + } + } + +} diff --git a/src/test/resources/com/blazemeter/jmeter/videostreaming/hls/mediaPlaylistPdtBeforeExtInf.m3u8 b/src/test/resources/com/blazemeter/jmeter/videostreaming/hls/mediaPlaylistPdtBeforeExtInf.m3u8 new file mode 100644 index 0000000..34b509d --- /dev/null +++ b/src/test/resources/com/blazemeter/jmeter/videostreaming/hls/mediaPlaylistPdtBeforeExtInf.m3u8 @@ -0,0 +1,10 @@ +#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-TARGETDURATION:4 +#EXT-X-MEDIA-SEQUENCE:100 +#EXT-X-PROGRAM-DATE-TIME:2026-06-02T16:33:11.352+0200 +#EXTINF:4, +segment_001.ts +#EXT-X-PROGRAM-DATE-TIME:2026-06-02T16:33:15.352+0200 +#EXTINF:4, +segment_002.ts diff --git a/src/test/resources/com/blazemeter/jmeter/videostreaming/hls/mediaPlaylistPdtBetweenExtInfAndUri.m3u8 b/src/test/resources/com/blazemeter/jmeter/videostreaming/hls/mediaPlaylistPdtBetweenExtInfAndUri.m3u8 new file mode 100644 index 0000000..05b9878 --- /dev/null +++ b/src/test/resources/com/blazemeter/jmeter/videostreaming/hls/mediaPlaylistPdtBetweenExtInfAndUri.m3u8 @@ -0,0 +1,14 @@ +#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-TARGETDURATION:4 +#EXT-X-MEDIA-SEQUENCE:17456 +#EXT-X-INDEPENDENT-SEGMENTS +#EXTINF:4, +#EXT-X-PROGRAM-DATE-TIME:2026-06-02T16:33:11.352+0200 +segment_001.ts +#EXTINF:4, +#EXT-X-PROGRAM-DATE-TIME:2026-06-02T16:33:15.352+0200 +segment_002.ts +#EXTINF:4, +#EXT-X-PROGRAM-DATE-TIME:2026-06-02T16:33:19.352+0200 +segment_003.ts