From 8d8130109d23893ca2614847ae624a3798fb5217 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 12 Apr 2026 00:45:42 -0400 Subject: [PATCH] fix: video/audio streaming instead of blob download Videos and audio now stream directly via URL with auth token query param instead of downloading entire file into a JS blob. Fixes playback of large videos (170MB+ was timing out). Images still use blob URLs. streamUrl() added to filebrowser client and cloud store. Co-Authored-By: Claude Opus 4.6 (1M context) --- neode-ui/src/api/filebrowser-client.ts | 13 +++++++++++++ neode-ui/src/components/cloud/MediaLightbox.vue | 16 +++++++++++++--- neode-ui/src/stores/cloud.ts | 5 +++++ neode-ui/src/views/CloudFolder.vue | 5 +++-- 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/neode-ui/src/api/filebrowser-client.ts b/neode-ui/src/api/filebrowser-client.ts index 5b7288ae..03560c32 100644 --- a/neode-ui/src/api/filebrowser-client.ts +++ b/neode-ui/src/api/filebrowser-client.ts @@ -122,6 +122,7 @@ class FileBrowserClient { /** * Fetch a file as a blob URL using header-based auth (no token in URL). * Use this for img/video/audio src attributes and download links. + * For large files (video/audio), prefer streamUrl() instead. */ async fetchBlobUrl(path: string): Promise { await this.ensureAuth() @@ -134,6 +135,18 @@ class FileBrowserClient { return URL.createObjectURL(blob) } + /** + * Get a direct streaming URL with auth token in query string. + * Use for video/audio where browser needs to stream (range requests). + * The token is a short-lived JWT so exposure in URL is acceptable. + */ + async streamUrl(path: string): Promise { + await this.ensureAuth() + const token = this.getAuthCookie() + const safePath = sanitizePath(path) + return `${this.baseUrl}/api/raw${safePath}?auth=${token}` + } + /** * Trigger a file download using header-based auth (no token in URL). */ diff --git a/neode-ui/src/components/cloud/MediaLightbox.vue b/neode-ui/src/components/cloud/MediaLightbox.vue index adc3d483..e6088655 100644 --- a/neode-ui/src/components/cloud/MediaLightbox.vue +++ b/neode-ui/src/components/cloud/MediaLightbox.vue @@ -117,6 +117,7 @@ const props = defineProps<{ startIndex: number show: boolean fetchBlobUrl: (path: string) => Promise + streamUrl?: (path: string) => Promise }>() const emit = defineEmits<{ @@ -167,9 +168,18 @@ async function loadMedia(item: FileBrowserItem) { if (cached) { currentUrl.value = cached } else { - const url = await props.fetchBlobUrl(item.path) - urlCache.set(item.path, url) - currentUrl.value = url + // Use streaming URL for video/audio (avoids downloading entire file into blob) + // Use blob URL for images (needed for rendering) + const isStreamable = isVideoFile(item) || isAudioFile(item) + if (isStreamable && props.streamUrl) { + const url = await props.streamUrl(item.path) + urlCache.set(item.path, url) + currentUrl.value = url + } else { + const url = await props.fetchBlobUrl(item.path) + urlCache.set(item.path, url) + currentUrl.value = url + } } } catch { mediaError.value = true diff --git a/neode-ui/src/stores/cloud.ts b/neode-ui/src/stores/cloud.ts index a2567778..88877ea8 100644 --- a/neode-ui/src/stores/cloud.ts +++ b/neode-ui/src/stores/cloud.ts @@ -99,6 +99,10 @@ export const useCloudStore = defineStore('cloud', () => { return fileBrowserClient.fetchBlobUrl(path) } + async function streamUrl(path: string): Promise { + return fileBrowserClient.streamUrl(path) + } + async function downloadFile(path: string): Promise { return fileBrowserClient.downloadFile(path) } @@ -125,6 +129,7 @@ export const useCloudStore = defineStore('cloud', () => { deleteItem, downloadUrl, fetchBlobUrl, + streamUrl, downloadFile, reset, } diff --git a/neode-ui/src/views/CloudFolder.vue b/neode-ui/src/views/CloudFolder.vue index 1d2b4947..023e8304 100644 --- a/neode-ui/src/views/CloudFolder.vue +++ b/neode-ui/src/views/CloudFolder.vue @@ -161,6 +161,7 @@ :start-index="lightboxIndex" :show="lightboxIndex !== null" :fetch-blob-url="cloudStore.fetchBlobUrl" + :stream-url="cloudStore.streamUrl" @close="lightboxIndex = null" /> @@ -358,8 +359,8 @@ async function handleDelete(path: string) { await cloudStore.deleteItem(path) } -function handlePlay(path: string, name: string) { - const url = cloudStore.downloadUrl(path) +async function handlePlay(path: string, name: string) { + const url = await cloudStore.streamUrl(path) audioPlayer.play(url, name) }