feat(blossom): route image fetches through local Blossom cache

Adds support for the local-blossom-cache spec
(https://github.com/hzrd149/blossom/blob/master/implementations/local-blossom-cache.md):
when http://127.0.0.1:24242 responds 2xx to HEAD /, image and video
fetches are routed through it with xs= upstream hints so it can proxy
on miss. Toggle defaults ON per account; disable from Media Servers
settings.

Covers both blossom:// URIs and plain http(s) URLs that carry an imeta
sha256, by rewriting the latter to a synthetic blossom:<sha>?xs=<host>
URI before handing it to Coil/ExoPlayer.
This commit is contained in:
Claude
2026-05-07 12:10:06 +00:00
parent 4538812d26
commit 9c4e87b937
15 changed files with 599 additions and 22 deletions
@@ -65,18 +65,38 @@ data class BlossomUri(
append(sha256)
append('.')
append(extension)
val params =
buildList {
servers.forEach { add("xs=${percentEncodeQueryValue(it)}") }
authors.forEach { add("as=$it") }
this@BlossomUri.size?.let { add("sz=$it") }
}
if (params.isNotEmpty()) {
append('?')
append(params.joinToString("&"))
}
appendQueryString()
}
/**
* Builds an HTTP URL pointing at a local Blossom cache that proxies
* upstream using the same `xs`/`as`/`sz` hints as the canonical URI.
*
* @param base the cache base URL, e.g. `http://127.0.0.1:24242`.
*/
fun toLocalCacheUrl(base: String): String =
buildString {
append(base.removeSuffix("/"))
append('/')
append(sha256)
append('.')
append(extension)
appendQueryString()
}
private fun StringBuilder.appendQueryString() {
val params =
buildList {
servers.forEach { add("xs=${percentEncodeQueryValue(it)}") }
authors.forEach { add("as=$it") }
this@BlossomUri.size?.let { add("sz=$it") }
}
if (params.isNotEmpty()) {
append('?')
append(params.joinToString("&"))
}
}
companion object {
private const val SCHEME = "blossom:"
@@ -151,4 +151,76 @@ class BlossomUriTest {
assertEquals(listOf(server1, server2), result.servers)
assertEquals(size, result.size)
}
@Test
fun toLocalCacheUrlMinimal() {
val uri =
BlossomUri(
sha256 = sha256,
extension = "jpg",
servers = emptyList(),
authors = emptyList(),
size = null,
)
assertEquals(
"http://127.0.0.1:24242/$sha256.jpg",
uri.toLocalCacheUrl("http://127.0.0.1:24242"),
)
}
@Test
fun toLocalCacheUrlIncludesHints() {
val uri =
BlossomUri(
sha256 = sha256,
extension = "mp4",
servers = listOf("https://cdn.example.com", "https://backup.example.com"),
authors = listOf(authorPubkey),
size = 1048576L,
)
// BlossomUri.percentEncodeQueryValue lets `:` and `/` through unencoded
// since they're safe in a query value as long as `&`/`=`/`#` are absent.
assertEquals(
"http://127.0.0.1:24242/$sha256.mp4" +
"?xs=https://cdn.example.com" +
"&xs=https://backup.example.com" +
"&as=$authorPubkey" +
"&sz=1048576",
uri.toLocalCacheUrl("http://127.0.0.1:24242"),
)
}
@Test
fun toLocalCacheUrlEncodesAmpersandInServer() {
val uri =
BlossomUri(
sha256 = sha256,
extension = "jpg",
servers = listOf("https://x.example.com/path?a=1&b=2"),
authors = emptyList(),
size = null,
)
// & and = inside the server URL must be encoded so they don't break the query.
assertEquals(
"http://127.0.0.1:24242/$sha256.jpg" +
"?xs=https://x.example.com/path?a%3D1%26b%3D2",
uri.toLocalCacheUrl("http://127.0.0.1:24242"),
)
}
@Test
fun toLocalCacheUrlStripsTrailingSlash() {
val uri =
BlossomUri(
sha256 = sha256,
extension = "jpg",
servers = emptyList(),
authors = emptyList(),
size = null,
)
assertEquals(
"http://127.0.0.1:24242/$sha256.jpg",
uri.toLocalCacheUrl("http://127.0.0.1:24242/"),
)
}
}