From 1bf8f43efc0541aba75210483c5d45d6e15f0d66 Mon Sep 17 00:00:00 2001 From: Prashant Kumar Date: Tue, 9 Jun 2026 03:05:31 +0530 Subject: [PATCH] fix(hls): Propagate variable definitions to child manifest parsing in HlsDownloader HlsDownloader.getSegments() now creates a HlsPlaylistParser with the multivariant playlist context and uses ParsingLoadable.load() to parse child manifests. This allows EXT-X-DEFINE variables defined in the multivariant playlist to be resolved via IMPORT in media playlists during the download path. Previously, child manifests were parsed via getManifest() which uses the original parser constructed with HlsMultivariantPlaylist.EMPTY. Variables imported via #EXT-X-DEFINE:IMPORT could not be resolved, causing segment URLs with unresolved {$variable} references to fail during download. This fixes HLS download failures for streams that define variables in the multivariant playlist and reference them in media playlist segment URLs (e.g. {$awsContentSegmentPrefix} in Art19 video podcasts). Issue: https://github.com/androidx/media/issues/3259 --- .../exoplayer/hls/offline/HlsDownloader.java | 21 ++- .../hls/offline/HlsDownloaderTest.java | 122 ++++++++++++++++++ 2 files changed, 141 insertions(+), 2 deletions(-) diff --git a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/offline/HlsDownloader.java b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/offline/HlsDownloader.java index f2ab72c551e..a91cdbbe738 100644 --- a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/offline/HlsDownloader.java +++ b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/offline/HlsDownloader.java @@ -29,6 +29,7 @@ import androidx.media3.exoplayer.hls.playlist.HlsPlaylist; import androidx.media3.exoplayer.hls.playlist.HlsPlaylistParser; import androidx.media3.exoplayer.offline.SegmentDownloader; +import androidx.media3.exoplayer.upstream.ParsingLoadable; import androidx.media3.exoplayer.upstream.ParsingLoadable.Parser; import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.io.IOException; @@ -220,21 +221,37 @@ private HlsDownloader( protected List getSegments(DataSource dataSource, HlsPlaylist manifest, boolean removing) throws IOException, InterruptedException { ArrayList mediaPlaylistDataSpecs = new ArrayList<>(); + HlsMultivariantPlaylist multivariantPlaylist = HlsMultivariantPlaylist.EMPTY; if (manifest instanceof HlsMultivariantPlaylist) { - HlsMultivariantPlaylist multivariantPlaylist = (HlsMultivariantPlaylist) manifest; + multivariantPlaylist = (HlsMultivariantPlaylist) manifest; addMediaPlaylistDataSpecs(multivariantPlaylist.mediaPlaylistUrls, mediaPlaylistDataSpecs); } else { mediaPlaylistDataSpecs.add( SegmentDownloader.getCompressibleDataSpec(Uri.parse(manifest.baseUri))); } + // When the multivariant playlist defines variables, create a parser that propagates + // them to child manifests for IMPORT resolution. Otherwise use the default path. + boolean hasVariables = !multivariantPlaylist.variableDefinitions.isEmpty(); + @Nullable HlsPlaylistParser childManifestParser = hasVariables + ? new HlsPlaylistParser(multivariantPlaylist, /* previousMediaPlaylist= */ null) + : null; + ArrayList segments = new ArrayList<>(); HashSet seenEncryptionKeyUris = new HashSet<>(); for (DataSpec mediaPlaylistDataSpec : mediaPlaylistDataSpecs) { segments.add(new Segment(/* startTimeUs= */ 0, mediaPlaylistDataSpec)); HlsMediaPlaylist mediaPlaylist; try { - mediaPlaylist = (HlsMediaPlaylist) getManifest(dataSource, mediaPlaylistDataSpec, removing); + if (childManifestParser != null) { + mediaPlaylist = + (HlsMediaPlaylist) + ParsingLoadable.load( + dataSource, childManifestParser, mediaPlaylistDataSpec, C.DATA_TYPE_MANIFEST); + } else { + mediaPlaylist = (HlsMediaPlaylist) getManifest(dataSource, mediaPlaylistDataSpec, + removing); + } } catch (IOException e) { if (!removing) { throw e; diff --git a/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/offline/HlsDownloaderTest.java b/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/offline/HlsDownloaderTest.java index 6c53ff2fd04..685f0e9ef7b 100644 --- a/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/offline/HlsDownloaderTest.java +++ b/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/offline/HlsDownloaderTest.java @@ -301,6 +301,128 @@ public void downloadEncMediaPlaylist() throws Exception { assertCachedData(cache, fakeDataSet); } + @Test + public void download_withVariableSubstitutionInSegmentUrls_resolvesVariables() throws Exception { + // Multivariant playlist defines a variable via EXT-X-DEFINE NAME/VALUE + byte[] multivariantPlaylistData = + ("#EXTM3U\n" + + "#EXT-X-DEFINE:NAME=\"cdnPrefix\",VALUE=\"https://cdn.example.com/\"\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=232370,CODECS=\"mp4a.40.2, avc1.4d4015\"\n" + + "media_with_vars.m3u8\n") + .getBytes(java.nio.charset.StandardCharsets.UTF_8); + + // Media playlist uses the variable in segment URLs with IMPORT + byte[] mediaPlaylistData = + ("#EXTM3U\n" + + "#EXT-X-TARGETDURATION:10\n" + + "#EXT-X-VERSION:8\n" + + "#EXT-X-MEDIA-SEQUENCE:0\n" + + "#EXT-X-PLAYLIST-TYPE:VOD\n" + + "#EXT-X-DEFINE:IMPORT=\"cdnPrefix\"\n" + + "#EXTINF:9.97667,\n" + + "{$cdnPrefix}segment0.ts\n" + + "#EXTINF:9.97667,\n" + + "{$cdnPrefix}segment1.ts\n" + + "#EXT-X-ENDLIST") + .getBytes(java.nio.charset.StandardCharsets.UTF_8); + + fakeDataSet = + new FakeDataSet() + .setData("master_vars.m3u8", multivariantPlaylistData) + .setData("media_with_vars.m3u8", mediaPlaylistData) + .setRandomData("https://cdn.example.com/segment0.ts", 10) + .setRandomData("https://cdn.example.com/segment1.ts", 11); + + HlsDownloader downloader = getHlsDownloader("master_vars.m3u8", getKeys(0)); + downloader.download(progressListener); + + // Verify segments were downloaded with resolved URLs + assertCachedData(cache, fakeDataSet); + } + + @Test + public void download_withMultipleVariablesInSegmentUrls_resolvesAllVariables() throws Exception { + // Multivariant playlist defines multiple variables + byte[] multivariantPlaylistData = + ("#EXTM3U\n" + + "#EXT-X-DEFINE:NAME=\"contentPrefix\",VALUE=\"https://media.example.com/\"\n" + + "#EXT-X-DEFINE:NAME=\"sessionId\",VALUE=\"abc123\"\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=500000,CODECS=\"avc1.4d401f,mp4a.40.2\"\n" + + "{$contentPrefix}video/720p.m3u8?sid={$sessionId}\n") + .getBytes(java.nio.charset.StandardCharsets.UTF_8); + + // Media playlist imports and uses both variables + byte[] mediaPlaylistData = + ("#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-VERSION:8\n" + + "#EXT-X-MEDIA-SEQUENCE:0\n" + + "#EXT-X-PLAYLIST-TYPE:VOD\n" + + "#EXT-X-DEFINE:IMPORT=\"contentPrefix\"\n" + + "#EXT-X-DEFINE:IMPORT=\"sessionId\"\n" + + "#EXTINF:6.0,\n" + + "{$contentPrefix}seg/s0.ts?sid={$sessionId}\n" + + "#EXTINF:6.0,\n" + + "{$contentPrefix}seg/s1.ts?sid={$sessionId}\n" + + "#EXT-X-ENDLIST") + .getBytes(java.nio.charset.StandardCharsets.UTF_8); + + fakeDataSet = + new FakeDataSet() + .setData("master_multi_vars.m3u8", multivariantPlaylistData) + .setData( + "https://media.example.com/video/720p.m3u8?sid=abc123", mediaPlaylistData) + .setRandomData( + "https://media.example.com/seg/s0.ts?sid=abc123", 10) + .setRandomData( + "https://media.example.com/seg/s1.ts?sid=abc123", 11); + + HlsDownloader downloader = getHlsDownloader("master_multi_vars.m3u8", getKeys(0)); + downloader.download(progressListener); + + assertCachedData(cache, fakeDataSet); + } + + @Test + public void download_withVariableInChildManifestUri_resolvesVariables() throws Exception { + // Multivariant playlist uses variable in the child manifest URI itself + byte[] multivariantPlaylistData = + ("#EXTM3U\n" + + "#EXT-X-DEFINE:NAME=\"manifestBase\",VALUE=\"https://manifest.example.com/\"\n" + + "#EXT-X-DEFINE:NAME=\"segmentBase\",VALUE=\"https://segments.example.com/\"\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=128000,CODECS=\"mp4a.40.2\"\n" + + "{$manifestBase}audio/playlist.m3u8\n") + .getBytes(java.nio.charset.StandardCharsets.UTF_8); + + // Media playlist uses a different variable for segment URLs + byte[] mediaPlaylistData = + ("#EXTM3U\n" + + "#EXT-X-TARGETDURATION:10\n" + + "#EXT-X-VERSION:8\n" + + "#EXT-X-MEDIA-SEQUENCE:0\n" + + "#EXT-X-PLAYLIST-TYPE:VOD\n" + + "#EXT-X-DEFINE:IMPORT=\"segmentBase\"\n" + + "#EXTINF:10.0,\n" + + "{$segmentBase}audio/chunk_0.ts\n" + + "#EXTINF:10.0,\n" + + "{$segmentBase}audio/chunk_1.ts\n" + + "#EXT-X-ENDLIST") + .getBytes(java.nio.charset.StandardCharsets.UTF_8); + + fakeDataSet = + new FakeDataSet() + .setData("master_child_var.m3u8", multivariantPlaylistData) + .setData( + "https://manifest.example.com/audio/playlist.m3u8", mediaPlaylistData) + .setRandomData("https://segments.example.com/audio/chunk_0.ts", 10) + .setRandomData("https://segments.example.com/audio/chunk_1.ts", 11); + + HlsDownloader downloader = getHlsDownloader("master_child_var.m3u8", getKeys(0)); + downloader.download(progressListener); + + assertCachedData(cache, fakeDataSet); + } + private HlsDownloader getHlsDownloader(String mediaPlaylistUri, List keys) { CacheDataSource.Factory cacheDataSourceFactory = new CacheDataSource.Factory()