fix(hls): master playlist URL had ..m3u8 double extension

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 <noreply@anthropic.com>
This commit is contained in:
davotoula
2026-04-14 20:33:08 +02:00
parent 1255ae3e61
commit 3bb9aba8fb
3 changed files with 50 additions and 3 deletions
@@ -126,7 +126,9 @@ class HlsUploadPipeline(
// Blossom servers typically return bare-hash URLs (https://server/<sha256>), 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 {
@@ -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)
@@ -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"))