refactor(hls): trust the upload server URL verbatim, drop withExtensionHint

Policy shift agreed with the server-side collaborator: the URL the
NIP-96 / Blossom server returns is already the optimal form — clean
Content-Type, correct cache keys, range-friendly — and the client
should not second-guess it. Stripping extensions, appending hints or
rewriting to bare sha256 all add round trips and cache misses for no
real win now that the WordPress plugin returns clean <hash>.m3u8 URLs.

Concretely:
- HlsUploadPipeline drops the withExtensionHint helper entirely. Every
  combinedUrl / mediaPlaylistUrl / masterUrl flows straight from the
  uploader's MediaUploadResult.url into the playlist rewrite and the
  HlsUploadResult.
- A small kdoc note at the top of the pipeline documents the policy
  ("if a server returns an unplayable URL, the fix is server-side").

Pipeline tests pruned:
- appendsMp4HintToBareCombinedUrlInMediaPlaylist — removed, was testing
  the appender that no longer exists.
- appendsM3u8HintToBarePlaylistUrlInMasterPlaylist — removed, same.
- trailingDotUrlsAreSanitisedIntoCleanExtension — removed, the Nip96
  fallback-extension map keeps the server from returning trailing-dot
  URLs in the first place.
- doesNotDoubleAppendExtensionWhenAlreadyPresent — removed.

Kept and reframed:
- serverUrlsAlreadyWithExtensionPassThroughUntouched — the canonical
  contract: server returns clean .m3u8 / .mp4, pipeline forwards
  verbatim.
- bareServerUrlsPassThroughVerbatim (new) — explicitly documents that
  even a bare-hash URL flows through unchanged; the pipeline does not
  try to make it playable.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
davotoula
2026-04-14 23:13:42 +02:00
parent a4787c5fdc
commit 610528cfcd
2 changed files with 20 additions and 96 deletions
@@ -58,6 +58,12 @@ data class HlsUploadedRendition(
* Finally rewrites the master playlist to reference the per-rendition playlist URLs and uploads * Finally rewrites the master playlist to reference the per-rendition playlist URLs and uploads
* the master. The resulting [HlsUploadResult] is what the publisher uses to build the NIP-71 * the master. The resulting [HlsUploadResult] is what the publisher uses to build the NIP-71
* event. * event.
*
* URL handling policy: the pipeline uses the URL the server returned verbatim. No extension is
* appended, no trailing dot stripped, no bare-hash rewriting. The server is responsible for
* returning a URL that the player can fetch as-is — that way we get the best cache coherence,
* correct Content-Type, clean range requests, and no double round trips. If a server returns an
* unplayable URL, the fix is server-side.
*/ */
class HlsUploadPipeline( class HlsUploadPipeline(
private val uploader: HlsBlobUploader, private val uploader: HlsBlobUploader,
@@ -75,10 +81,7 @@ class HlsUploadPipeline(
val combined = uploader.upload(rendition.combinedFile, CONTENT_TYPE_VIDEO_MP4) val combined = uploader.upload(rendition.combinedFile, CONTENT_TYPE_VIDEO_MP4)
onProgress(++done, total) onProgress(++done, total)
val combinedUrl = val combinedUrl =
withExtensionHint( combined.url ?: error("Uploader returned null URL for ${rendition.combinedFile.name}")
combined.url ?: error("Uploader returned null URL for ${rendition.combinedFile.name}"),
CONTENT_TYPE_VIDEO_MP4,
)
val rewrittenMedia = val rewrittenMedia =
HlsPlaylistRewriter.rewrite( HlsPlaylistRewriter.rewrite(
@@ -90,10 +93,7 @@ class HlsUploadPipeline(
val mediaPlaylist = uploader.upload(mediaPlaylistFile, CONTENT_TYPE_HLS) val mediaPlaylist = uploader.upload(mediaPlaylistFile, CONTENT_TYPE_HLS)
onProgress(++done, total) onProgress(++done, total)
val mediaPlaylistUrl = val mediaPlaylistUrl =
withExtensionHint( mediaPlaylist.url ?: error("Uploader returned null URL for media playlist ${rendition.label}")
mediaPlaylist.url ?: error("Uploader returned null URL for media playlist ${rendition.label}"),
CONTENT_TYPE_HLS,
)
HlsUploadedRendition( HlsUploadedRendition(
label = rendition.label, label = rendition.label,
@@ -112,10 +112,7 @@ class HlsUploadPipeline(
val master = uploader.upload(masterFile, CONTENT_TYPE_HLS) val master = uploader.upload(masterFile, CONTENT_TYPE_HLS)
onProgress(++done, total) onProgress(++done, total)
val masterUrl = val masterUrl =
withExtensionHint( master.url ?: error("Uploader returned null URL for master playlist")
master.url ?: error("Uploader returned null URL for master playlist"),
CONTENT_TYPE_HLS,
)
return HlsUploadResult( return HlsUploadResult(
masterUrl = masterUrl, masterUrl = masterUrl,
@@ -124,28 +121,6 @@ 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. 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,
): String {
val ext =
when (contentType) {
CONTENT_TYPE_VIDEO_MP4 -> ".mp4"
CONTENT_TYPE_HLS -> ".m3u8"
else -> return url
}
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 { companion object {
const val CONTENT_TYPE_VIDEO_MP4 = "video/mp4" const val CONTENT_TYPE_VIDEO_MP4 = "video/mp4"
const val CONTENT_TYPE_HLS = "application/vnd.apple.mpegurl" const val CONTENT_TYPE_HLS = "application/vnd.apple.mpegurl"
@@ -180,36 +180,25 @@ class HlsUploadPipelineTest {
} }
@Test @Test
fun appendsMp4HintToBareCombinedUrlInMediaPlaylist() { fun bareServerUrlsPassThroughVerbatim() {
val bundle = createBundle(listOf("360p")) // Policy: the pipeline uses whatever URL the server returned, unchanged.
val uploader = BareUrlUploader() // Even a bare-hash URL with no extension flows straight into the rewritten
val pipeline = HlsUploadPipeline(uploader) // playlists. If it does not play, the fix is server-side (return a playable URL).
runBlocking { pipeline.upload(bundle) }
val uploadedMediaPlaylist = uploader.calls[1].third
assertTrue(
"media playlist should reference url with .mp4 hint",
uploadedMediaPlaylist.contains("https://blossom.test/bare-1.mp4"),
)
assertTrue(!uploadedMediaPlaylist.contains("https://blossom.test/bare-1\""))
}
@Test
fun appendsM3u8HintToBarePlaylistUrlInMasterPlaylist() {
val bundle = createBundle(listOf("360p")) val bundle = createBundle(listOf("360p"))
val uploader = BareUrlUploader() val uploader = BareUrlUploader()
val pipeline = HlsUploadPipeline(uploader) val pipeline = HlsUploadPipeline(uploader)
val result = runBlocking { pipeline.upload(bundle) } val result = runBlocking { pipeline.upload(bundle) }
val uploadedMaster = uploader.calls[2].third assertEquals("https://blossom.test/bare-1", result.renditions[0].combinedUrl)
assertEquals("https://blossom.test/bare-2", result.renditions[0].playlistUrl)
assertEquals("https://blossom.test/bare-3", result.masterUrl)
// The rewritten media playlist that the server received must contain the bare url.
val uploadedMediaPlaylist = uploader.calls[1].third
assertTrue( assertTrue(
"master playlist should reference playlist url with .m3u8 hint", "media playlist should reference bare url: $uploadedMediaPlaylist",
uploadedMaster.contains("https://blossom.test/bare-2.m3u8"), uploadedMediaPlaylist.contains("https://blossom.test/bare-1"),
) )
assertEquals("https://blossom.test/bare-3.m3u8", result.masterUrl)
assertEquals("https://blossom.test/bare-1.mp4", result.renditions[0].combinedUrl)
} }
@Test @Test
@@ -252,46 +241,6 @@ class HlsUploadPipelineTest {
assertTrue(!result.renditions[0].playlistUrl.contains(".m3u8.m3u8")) assertTrue(!result.renditions[0].playlistUrl.contains(".m3u8.m3u8"))
} }
@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"))
val uploader = FakeUploader() // returns urls ending in .mp4 / .m3u8
val pipeline = HlsUploadPipeline(uploader)
runBlocking { pipeline.upload(bundle) }
val uploadedMediaPlaylist = uploader.calls[1].content
assertTrue(!uploadedMediaPlaylist.contains(".mp4.mp4"))
val uploadedMaster = uploader.calls[2].content
assertTrue(!uploadedMaster.contains(".m3u8.m3u8"))
}
@Test @Test
fun reportsUploadProgressPerStep() { fun reportsUploadProgressPerStep() {
val bundle = createBundle(listOf("360p", "540p")) val bundle = createBundle(listOf("360p", "540p"))