From 3bb9aba8fb0069865f511c709b478193284a8087 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 14 Apr 2026 20:33:08 +0200 Subject: [PATCH] fix(hls): master playlist URL had ..m3u8 double extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: Android's MimeTypeMap does not know application/vnd.apple.mpegurl, so Nip96Uploader.upload() resolved the extension to "" and sent the multipart filename as "abc." (trailing dot). The NIP-96 server then echoed the upload name into its returned URL, which arrived at the pipeline ending in ".". The pipeline's withExtensionHint helper appended ".m3u8" on top of that, producing "..m3u8" — which the subsequent playback GET 404s against because the server stored the file at the single-dot path. Two fixes, both needed: 1. Nip96Uploader.upload(InputStream, ...) now falls back to a small static MIME->extension table when MimeTypeMap returns null, covering the HLS playlist types and video/mp4 / fMP4 segment types. The file is uploaded with a real ".m3u8" / ".mp4" extension, so the server never has to invent one. 2. HlsUploadPipeline.withExtensionHint now trims trailing dots and collapses any existing "..ext" sequences before checking whether to append, so a server that still echoes a single trailing dot cannot produce unreachable URLs. Regression test locks in that a fake uploader returning a "https://server/bare-1." URL yields a single-dot ".m3u8" in the final upload result. Co-Authored-By: Claude Opus 4.5 --- .../service/uploads/hls/HlsUploadPipeline.kt | 10 +++++-- .../service/uploads/nip96/Nip96Uploader.kt | 17 +++++++++++- .../uploads/hls/HlsUploadPipelineTest.kt | 26 +++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipeline.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipeline.kt index 4c0221dbd..6946dca48 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipeline.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipeline.kt @@ -126,7 +126,9 @@ class HlsUploadPipeline( // Blossom servers typically return bare-hash URLs (https://server/), but HLS parsers // and ExoPlayer's Util.inferContentType sniff the URL extension to pick the right source - // factory. Append a hint unless the upload server already baked one in. + // factory. Append a hint unless the upload server already baked one in. Trailing dots are + // trimmed and any pre-existing doubled extension (e.g. "..m3u8") collapsed, so a server that + // echoes our empty-extension upload filename does not produce unreachable URLs. private fun withExtensionHint( url: String, contentType: String, @@ -137,7 +139,11 @@ class HlsUploadPipeline( CONTENT_TYPE_HLS -> ".m3u8" else -> return url } - return if (url.endsWith(ext, ignoreCase = true)) url else url + ext + val sanitised = + url + .replace(Regex("""\.{2,}""" + Regex.escape(ext.trimStart('.')) + "$", RegexOption.IGNORE_CASE), ext) + .trimEnd('.') + return if (sanitised.endsWith(ext, ignoreCase = true)) sanitised else sanitised + ext } companion object { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt index 5b4317988..2950a3fc0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt @@ -144,7 +144,10 @@ class Nip96Uploader { checkNotInMainThread() val fileName = RandomInstance.randomChars(16) - val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" + val extension = + contentType?.let { + MimeTypeMap.getSingleton().getExtensionFromMimeType(it) ?: fallbackExtensionForMimeType(it) + } ?: "" val client = okHttpClient(server.apiUrl) val requestBuilder = Request.Builder() @@ -231,6 +234,18 @@ class Nip96Uploader { fun String.displayUrl() = this.removeSuffix("/").removePrefix("https://") + // Android's MimeTypeMap does not know every MIME we upload (notably HLS playlist types). + // When it returns null we fall back to a small static table so the multipart filename still + // carries a real extension — otherwise the server gets "name." and echoes it back, which + // breaks HLS URL rewriting. + private fun fallbackExtensionForMimeType(mimeType: String): String? = + when (mimeType.lowercase()) { + "application/vnd.apple.mpegurl", "application/x-mpegurl", "audio/x-mpegurl", "audio/mpegurl" -> "m3u8" + "video/mp2t" -> "ts" + "video/iso.segment", "video/mp4" -> "mp4" + else -> null + } + fun convertToMediaResult(nip96: PartialEvent): MediaUploadResult { // Images don't seem to be ready immediately after upload val imageUrl = nip96.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipelineTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipelineTest.kt index 06a6a9951..346abb454 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipelineTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipelineTest.kt @@ -212,6 +212,32 @@ class HlsUploadPipelineTest { assertEquals("https://blossom.test/bare-1.mp4", result.renditions[0].combinedUrl) } + @Test + fun trailingDotUrlsAreSanitisedIntoCleanExtension() { + val bundle = createBundle(listOf("360p")) + val uploader = + object : HlsBlobUploader { + var count = 0 + + override suspend fun upload( + file: File, + contentType: String, + ): MediaUploadResult { + count++ + return MediaUploadResult(url = "https://blossom.test/bare-$count.", sha256 = "sha-$count", size = file.length()) + } + } + val pipeline = HlsUploadPipeline(uploader) + + val result = runBlocking { pipeline.upload(bundle) } + + assertTrue("masterUrl must not contain '..': ${result.masterUrl}", !result.masterUrl.contains("..")) + assertTrue("masterUrl must not end with '.': ${result.masterUrl}", !result.masterUrl.endsWith(".")) + assertTrue(result.masterUrl.endsWith(".m3u8")) + assertTrue(!result.renditions[0].combinedUrl.contains("..")) + assertTrue(result.renditions[0].combinedUrl.endsWith(".mp4")) + } + @Test fun doesNotDoubleAppendExtensionWhenAlreadyPresent() { val bundle = createBundle(listOf("360p"))