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:
+8
-2
@@ -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 {
|
||||
|
||||
+16
-1
@@ -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)
|
||||
|
||||
+26
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user