feat(#920): adopt GET /api/files/stream for seekable audio and video playback (#930)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-18 12:02:36 +03:00
1 parent 7232395387
commit 50a0f30d8f
4 files changed
+70 -8

No files matched your search

@@ -34,6 +34,7 @@ import kotlin.coroutines.coroutineContext
*/ */
object GatewayFileClient { object GatewayFileClient {
private const val DOWNLOAD_PATH = "/api/files/download" private const val DOWNLOAD_PATH = "/api/files/download"
private const val STREAM_PATH = "/api/files/stream"
/** Downloads are reused for this long before a fresh fetch is made. */ /** Downloads are reused for this long before a fresh fetch is made. */
private const val CACHE_REUSE_TTL_MS = 10 * 60 * 1000L private const val CACHE_REUSE_TTL_MS = 10 * 60 * 1000L
@@ -46,13 +47,52 @@ object GatewayFileClient {
/** /**
* Pure builder for the authenticated download URL. * Pure builder for the authenticated download URL.
* *
* @return the full URL, or `null` if [baseUrl]/[token] are blank or * @return the full URL, or `null` if [baseUrl] is blank or [path] is not an
* [path] is not an absolute (or `~/`) host path. * absolute (or `~/`) host path.
*/ */
fun buildDownloadUrl( fun buildDownloadUrl(
baseUrl: String, baseUrl: String,
token: String, token: String,
path: String, path: String,
): String? = buildUrl(baseUrl, token, path, DOWNLOAD_PATH)
/**
* Pure builder for the authenticated media streaming URL (`/api/files/stream`).
*
* Supports HTTP Range requests and inline content disposition for seekable audio & video (issue #920).
*/
fun buildStreamUrl(
baseUrl: String,
token: String,
path: String,
): String? = buildUrl(baseUrl, token, path, STREAM_PATH)
/**
* Build the best URL for a gateway host file: `/api/files/stream` for seekable audio/video
* with Range support (issue #920), and `/api/files/download` for images, documents, and other files.
*/
fun buildMediaUrl(
baseUrl: String,
token: String,
path: String,
): String? {
val kind = com.m57.hermescontrol.ui.chat.mediaKindForPath(path)
val endpoint =
if (kind == com.m57.hermescontrol.ui.chat.MediaKind.AUDIO ||
kind == com.m57.hermescontrol.ui.chat.MediaKind.VIDEO
) {
STREAM_PATH
} else {
DOWNLOAD_PATH
}
return buildUrl(baseUrl, token, path, endpoint)
}
private fun buildUrl(
baseUrl: String,
token: String,
path: String,
endpointPath: String,
): String? { ): String? {
val trimmedBase = baseUrl.trimEnd('/') val trimmedBase = baseUrl.trimEnd('/')
if (trimmedBase.isBlank()) return null if (trimmedBase.isBlank()) return null
@@ -71,7 +111,7 @@ object GatewayFileClient {
} else { } else {
"" ""
} }
return "$trimmedBase$DOWNLOAD_PATH?path=$encPath$authQuery" return "$trimmedBase$endpointPath?path=$encPath$authQuery"
} }
/** /**
@@ -198,7 +238,7 @@ object GatewayFileClient {
cacheDir: File, cacheDir: File,
cacheName: String, cacheName: String,
): File? { ): File? {
val body = resp.body ?: return null val body = resp.body
val dir = cacheDir.also { it.mkdirs() } val dir = cacheDir.also { it.mkdirs() }
sweepOldCacheFiles(dir) sweepOldCacheFiles(dir)
val target = File(dir, cacheName) val target = File(dir, cacheName)
@@ -2867,7 +2867,7 @@ class ChatViewModel(
items items
.mapNotNull { item -> .mapNotNull { item ->
val url = val url =
GatewayFileClient.buildDownloadUrl( GatewayFileClient.buildMediaUrl(
baseUrl, baseUrl,
token, token,
item.path, item.path,
@@ -2985,7 +2985,8 @@ class ChatViewModel(
.toSet() .toSet()
val newAttachments = val newAttachments =
items.mapNotNull { item -> items.mapNotNull { item ->
val url = GatewayFileClient.buildDownloadUrl(baseUrl, "", item.path) ?: return@mapNotNull null val token = AuthManager.getToken().orEmpty()
val url = GatewayFileClient.buildMediaUrl(baseUrl, token, item.path) ?: return@mapNotNull null
if (url in existingUrls) return@mapNotNull null if (url in existingUrls) return@mapNotNull null
Attachment( Attachment(
uri = url, uri = url,
@@ -1062,8 +1062,8 @@ private fun resolveImageUrl(uri: String): String {
} }
val baseUrl = runCatching { AuthManager.getBaseUrl() }.getOrDefault("") val baseUrl = runCatching { AuthManager.getBaseUrl() }.getOrDefault("")
val token = runCatching { AuthManager.getToken() }.getOrNull().orEmpty() val token = runCatching { AuthManager.getToken() }.getOrNull().orEmpty()
val downloadUrl = GatewayFileClient.buildDownloadUrl(baseUrl, token, trimmed) val mediaUrl = GatewayFileClient.buildMediaUrl(baseUrl, token, trimmed)
if (downloadUrl != null) return downloadUrl if (mediaUrl != null) return mediaUrl
if (baseUrl.isNotBlank() && (trimmed.startsWith("/api/") || trimmed.startsWith("api/"))) { if (baseUrl.isNotBlank() && (trimmed.startsWith("/api/") || trimmed.startsWith("api/"))) {
val cleanPath = if (trimmed.startsWith("/")) trimmed else "/$trimmed" val cleanPath = if (trimmed.startsWith("/")) trimmed else "/$trimmed"
@@ -76,6 +76,27 @@ class GatewayFileClientTest {
assertFalse(url.contains("MEDIA:")) assertFalse(url.contains("MEDIA:"))
} }
@Test
fun `buildStreamUrl encodes path and token with stream endpoint`() {
val url = GatewayFileClient.buildStreamUrl(base, tok, "/tmp/video.mp4")!!
assertTrue(url.startsWith("$base/api/files/stream?"))
assertTrue(url.contains("path=%2Ftmp%2Fvideo.mp4"))
assertTrue(url.contains("token="))
}
@Test
fun `buildMediaUrl routes audio and video to stream and others to download`() {
val videoUrl = GatewayFileClient.buildMediaUrl(base, tok, "/tmp/clip.mp4")!!
val audioUrl = GatewayFileClient.buildMediaUrl(base, tok, "/tmp/voice.ogg")!!
val imageUrl = GatewayFileClient.buildMediaUrl(base, tok, "/tmp/photo.jpg")!!
val docUrl = GatewayFileClient.buildMediaUrl(base, tok, "/tmp/report.pdf")!!
assertTrue(videoUrl.startsWith("$base/api/files/stream?"))
assertTrue(audioUrl.startsWith("$base/api/files/stream?"))
assertTrue(imageUrl.startsWith("$base/api/files/download?"))
assertTrue(docUrl.startsWith("$base/api/files/download?"))
}
@Test @Test
fun `normalizePath expands tilde`() { fun `normalizePath expands tilde`() {
val home = System.getenv("HOME") ?: "/home/test" val home = System.getenv("HOME") ?: "/home/test"