From 50a0f30d8f6b82a47a1c8bda9d3e80f98e08f51e Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:02:36 +0300 Subject: [PATCH] feat(#920): adopt GET /api/files/stream for seekable audio and video playback (#930) Co-authored-by: M57 --- .../data/remote/GatewayFileClient.kt | 48 +++++++++++++++++-- .../hermescontrol/ui/chat/ChatViewModel.kt | 5 +- .../m57/hermescontrol/ui/chat/MarkdownText.kt | 4 +- .../data/remote/GatewayFileClientTest.kt | 21 ++++++++ 4 files changed, 70 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt index ed7d7245..e00234a0 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt @@ -34,6 +34,7 @@ import kotlin.coroutines.coroutineContext */ object GatewayFileClient { 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. */ private const val CACHE_REUSE_TTL_MS = 10 * 60 * 1000L @@ -46,13 +47,52 @@ object GatewayFileClient { /** * Pure builder for the authenticated download URL. * - * @return the full URL, or `null` if [baseUrl]/[token] are blank or - * [path] is not an absolute (or `~/`) host path. + * @return the full URL, or `null` if [baseUrl] is blank or [path] is not an + * absolute (or `~/`) host path. */ fun buildDownloadUrl( baseUrl: String, token: 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? { val trimmedBase = baseUrl.trimEnd('/') if (trimmedBase.isBlank()) return null @@ -71,7 +111,7 @@ object GatewayFileClient { } else { "" } - return "$trimmedBase$DOWNLOAD_PATH?path=$encPath$authQuery" + return "$trimmedBase$endpointPath?path=$encPath$authQuery" } /** @@ -198,7 +238,7 @@ object GatewayFileClient { cacheDir: File, cacheName: String, ): File? { - val body = resp.body ?: return null + val body = resp.body val dir = cacheDir.also { it.mkdirs() } sweepOldCacheFiles(dir) val target = File(dir, cacheName) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index a58bc3b6..7bf57b96 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -2867,7 +2867,7 @@ class ChatViewModel( items .mapNotNull { item -> val url = - GatewayFileClient.buildDownloadUrl( + GatewayFileClient.buildMediaUrl( baseUrl, token, item.path, @@ -2985,7 +2985,8 @@ class ChatViewModel( .toSet() val newAttachments = 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 Attachment( uri = url, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt index 5c460d98..b1ecc297 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt @@ -1062,8 +1062,8 @@ private fun resolveImageUrl(uri: String): String { } val baseUrl = runCatching { AuthManager.getBaseUrl() }.getOrDefault("") val token = runCatching { AuthManager.getToken() }.getOrNull().orEmpty() - val downloadUrl = GatewayFileClient.buildDownloadUrl(baseUrl, token, trimmed) - if (downloadUrl != null) return downloadUrl + val mediaUrl = GatewayFileClient.buildMediaUrl(baseUrl, token, trimmed) + if (mediaUrl != null) return mediaUrl if (baseUrl.isNotBlank() && (trimmed.startsWith("/api/") || trimmed.startsWith("api/"))) { val cleanPath = if (trimmed.startsWith("/")) trimmed else "/$trimmed" diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/GatewayFileClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/GatewayFileClientTest.kt index d4318412..8f4103b3 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/GatewayFileClientTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/GatewayFileClientTest.kt @@ -76,6 +76,27 @@ class GatewayFileClientTest { 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 fun `normalizePath expands tilde`() { val home = System.getenv("HOME") ?: "/home/test"