From 80b7b38b27bc5fbaef9c77887ca2f15f58cd67f4 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 14 Apr 2026 15:18:55 +0200 Subject: [PATCH] feat(hls): add HlsPlaylistRewriter for post-upload URL substitution Pure rewriter that walks HLS master or media playlists line-by-line and substitutes each resource reference (segment file, EXT-X-MAP init, variant media.m3u8) with its uploaded absolute URL. Preserves every #EXT-X-STREAM-INF, #EXTINF and other directive line verbatim so the BANDWIDTH/RESOLUTION/CODECS attributes that ExoPlayer's AdaptiveTrackSelection reads stay intact. Loud failure on missing entries in the URL map to avoid silent data loss. Bumps lightcompressor-enhanced to 2.1.0 for the HlsPreparer API that the following milestones will wrap. Milestone 1 of the HLS video sharing plan (2026-04-13). Co-Authored-By: Claude Opus 4.5 --- .../uploads/hls/HlsPlaylistRewriter.kt | 49 ++++ .../uploads/hls/HlsPlaylistRewriterTest.kt | 234 ++++++++++++++++++ gradle/libs.versions.toml | 2 +- 3 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPlaylistRewriter.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPlaylistRewriterTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPlaylistRewriter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPlaylistRewriter.kt new file mode 100644 index 0000000000..cae04678c2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPlaylistRewriter.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.uploads.hls + +object HlsPlaylistRewriter { + private val uriRegex = Regex("""URI="([^"]+)"""") + + fun rewrite( + playlist: String, + urlMap: Map, + ): String = + playlist.lines().joinToString("\n") { line -> + when { + line.isBlank() -> line + line.startsWith("#") -> rewriteUriInDirective(line, urlMap) + else -> urlMap[line] ?: missing(line) + } + } + + private fun rewriteUriInDirective( + line: String, + urlMap: Map, + ): String = + uriRegex.replace(line) { match -> + val original = match.groupValues[1] + val rewritten = urlMap[original] ?: missing(original) + """URI="$rewritten"""" + } + + private fun missing(reference: String): Nothing = throw IllegalArgumentException("No uploaded URL for playlist reference: $reference") +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPlaylistRewriterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPlaylistRewriterTest.kt new file mode 100644 index 0000000000..7c1b725764 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPlaylistRewriterTest.kt @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.uploads.hls + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class HlsPlaylistRewriterTest { + @Test + fun rewritesSegmentReferencesInMediaPlaylist() { + val playlist = + """ + #EXTM3U + #EXT-X-VERSION:7 + #EXT-X-TARGETDURATION:4 + #EXT-X-MEDIA-SEQUENCE:0 + #EXTINF:4.000, + segment_000.m4s + #EXTINF:4.000, + segment_001.m4s + #EXT-X-ENDLIST + """.trimIndent() + + val urlMap = + mapOf( + "segment_000.m4s" to "https://cdn.example.com/abc.m4s", + "segment_001.m4s" to "https://cdn.example.com/def.m4s", + ) + + val rewritten = HlsPlaylistRewriter.rewrite(playlist, urlMap) + + val expected = + """ + #EXTM3U + #EXT-X-VERSION:7 + #EXT-X-TARGETDURATION:4 + #EXT-X-MEDIA-SEQUENCE:0 + #EXTINF:4.000, + https://cdn.example.com/abc.m4s + #EXTINF:4.000, + https://cdn.example.com/def.m4s + #EXT-X-ENDLIST + """.trimIndent() + + assertEquals(expected, rewritten) + } + + @Test + fun preservesExtInfLinesExactly() { + val playlist = + """ + #EXTINF:3.9836, + segment_000.m4s + """.trimIndent() + + val rewritten = + HlsPlaylistRewriter.rewrite( + playlist, + mapOf("segment_000.m4s" to "https://cdn/x.m4s"), + ) + + assertEquals( + "#EXTINF:3.9836,\nhttps://cdn/x.m4s", + rewritten, + ) + } + + @Test + fun rewritesExtXMapUri() { + val playlist = + """ + #EXTM3U + #EXT-X-MAP:URI="init.mp4" + #EXTINF:4.000, + segment_000.m4s + """.trimIndent() + + val urlMap = + mapOf( + "init.mp4" to "https://cdn/init-abc.mp4", + "segment_000.m4s" to "https://cdn/seg-def.m4s", + ) + + val rewritten = HlsPlaylistRewriter.rewrite(playlist, urlMap) + + val expected = + """ + #EXTM3U + #EXT-X-MAP:URI="https://cdn/init-abc.mp4" + #EXTINF:4.000, + https://cdn/seg-def.m4s + """.trimIndent() + + assertEquals(expected, rewritten) + } + + @Test + fun extXMapPreservesAdditionalAttributes() { + val playlist = """#EXT-X-MAP:URI="init.mp4",BYTERANGE="718@0"""" + + val rewritten = + HlsPlaylistRewriter.rewrite( + playlist, + mapOf("init.mp4" to "https://cdn/abc.mp4"), + ) + + assertEquals( + """#EXT-X-MAP:URI="https://cdn/abc.mp4",BYTERANGE="718@0"""", + rewritten, + ) + } + + @Test + fun rewritesVariantsInMasterPlaylist() { + val playlist = + """ + #EXTM3U + #EXT-X-VERSION:7 + #EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,CODECS="avc1.64001e,mp4a.40.2" + 360p/media.m3u8 + #EXT-X-STREAM-INF:BANDWIDTH=2400000,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2" + 720p/media.m3u8 + """.trimIndent() + + val urlMap = + mapOf( + "360p/media.m3u8" to "https://cdn/360.m3u8", + "720p/media.m3u8" to "https://cdn/720.m3u8", + ) + + val rewritten = HlsPlaylistRewriter.rewrite(playlist, urlMap) + + val expected = + """ + #EXTM3U + #EXT-X-VERSION:7 + #EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,CODECS="avc1.64001e,mp4a.40.2" + https://cdn/360.m3u8 + #EXT-X-STREAM-INF:BANDWIDTH=2400000,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2" + https://cdn/720.m3u8 + """.trimIndent() + + assertEquals(expected, rewritten) + } + + @Test + fun preservesExtXStreamInfLinesExactly() { + val playlist = + """ + #EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,CODECS="avc1.64001e,mp4a.40.2" + 360p/media.m3u8 + """.trimIndent() + + val rewritten = + HlsPlaylistRewriter.rewrite( + playlist, + mapOf("360p/media.m3u8" to "https://cdn/360.m3u8"), + ) + + val expected = + """ + #EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,CODECS="avc1.64001e,mp4a.40.2" + https://cdn/360.m3u8 + """.trimIndent() + + assertEquals(expected, rewritten) + } + + @Test + fun leavesBlankLinesAndCommentsUnchanged() { + val playlist = + """ + #EXTM3U + # this is a comment + + #EXT-X-VERSION:7 + #EXTINF:4.000, + segment_000.m4s + """.trimIndent() + + val rewritten = + HlsPlaylistRewriter.rewrite( + playlist, + mapOf("segment_000.m4s" to "https://cdn/x.m4s"), + ) + + val expected = + """ + #EXTM3U + # this is a comment + + #EXT-X-VERSION:7 + #EXTINF:4.000, + https://cdn/x.m4s + """.trimIndent() + + assertEquals(expected, rewritten) + } + + @Test + fun throwsWhenSegmentReferenceIsMissingFromUrlMap() { + val playlist = + """ + #EXTINF:4.000, + segment_000.m4s + """.trimIndent() + + val ex = + assertThrows(IllegalArgumentException::class.java) { + HlsPlaylistRewriter.rewrite(playlist, emptyMap()) + } + + assertEquals("No uploaded URL for playlist reference: segment_000.m4s", ex.message) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6bb0a4be3f..1c7c34c03d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -38,7 +38,7 @@ genaiPrompt = "1.0.0-beta2" genaiRewriting = "1.0.0-beta1" languageId = "17.0.6" lifecycleRuntimeKtx = "2.10.0" -lightcompressor-enhanced = "2.0.0" +lightcompressor-enhanced = "2.1.0" markdown = "f92ef49c9d" material3 = "1.9.0" materialIconsExtended = "1.7.3"