From f18d19e7d241e82f8ec6b91d0f8068c7e4d5c9f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 28 Jul 2026 14:19:07 +0000 Subject: [PATCH] =?UTF-8?q?file=20browser:=20video=20download=20=E2=80=94?= =?UTF-8?q?=20metadata=20prefetch=20+=20playlist=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the download-video dialog into a prefetch-then-download flow, mirroring ReClip's own web UI. Still a pure proxy to ReClip (its yt-dlp); no downloader logic moves to the platform. Server (thin ReClip proxies alongside /download-video): - POST /file-browser/video-info { url } → ReClip /api/info → { title, thumbnail, duration, uploader } - POST /file-browser/video-playlist { url } → ReClip /api/playlist → { urls } Both return { error } inline (200) so the client can render failures per-card. UI (VideoDownloadDialog, now self-contained; useFileBrowserApp exposes `files` and drops the old single-shot state/handler): - Paste a URL → Fetch. A playlist URL (list=) expands via /video-playlist, then each entry's /video-info is prefetched sequentially (ReClip does yt-dlp per video), rendering a card (thumbnail, title, uploader, duration) that fills in progressively. - Per-entry Download, plus Download All when there's more than one; per-card status (downloading → saving → saved / retry-on-error) via the existing background job + poll. - Playlists get an optional "subfolder you name" field (ReClip's /api/playlist carries no playlist title); blank = current folder. - Quality is always best (matches the mobile Share flow — no picker); the audio-only toggle applies to the whole batch. Verified ReClip's contract live: /api/info returns the metadata fields, and /api/playlist returns { urls } (17 entries in ~1.2s). Co-Authored-By: Claude Opus 4.8 --- src/servers/api/file-browser/router.ts | 33 ++ .../components/VideoDownloadDialog.tsx | 341 +++++++++++++++--- .../FileBrowserApp/useFileBrowserApp.ts | 50 +-- .../officerdev/src/hooks/useFilesAPI.ts | 15 + 4 files changed, 343 insertions(+), 96 deletions(-) diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts index 1044be49..9ef3b1aa 100644 --- a/src/servers/api/file-browser/router.ts +++ b/src/servers/api/file-browser/router.ts @@ -1328,6 +1328,39 @@ router.get('/download-video/:jobId', (ctx) => { return ctx.json({ status: job.status, error: job.error, filename: job.filename }); }); +// Prefetch a single video's metadata (title / thumbnail / duration / uploader) — proxied straight to +// ReClip's /api/info so the download dialog can show a preview card before committing. Errors (private +// video, timeout, …) come back as { error } with a 200 so the client can render them inline. +router.post('/video-info', async (ctx) => { + const { url } = ctx.get('body') as { url?: string }; + if (!url) throw errors.BAD_REQUEST('url is required'); + const res = await fetch(`${RECLIP_BASE}/api/info`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }), + signal: AbortSignal.timeout(90_000), + }).catch(() => null); + if (!res) return ctx.json({ error: `Could not reach ReClip at ${RECLIP_BASE}` }); + const data = (await res.json().catch(() => ({}))) as Record; + return ctx.json(data); +}); + +// Expand a playlist URL into its individual video URLs (ReClip's /api/playlist → { urls }). The client +// then prefetches /video-info per url to build the per-entry cards. +router.post('/video-playlist', async (ctx) => { + const { url } = ctx.get('body') as { url?: string }; + if (!url) throw errors.BAD_REQUEST('url is required'); + const res = await fetch(`${RECLIP_BASE}/api/playlist`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }), + signal: AbortSignal.timeout(120_000), + }).catch(() => null); + if (!res) return ctx.json({ error: `Could not reach ReClip at ${RECLIP_BASE}` }); + const data = (await res.json().catch(() => ({}))) as Record; + return ctx.json(data); +}); + // Git clone a repository into a directory router.post('/git-clone', async (ctx) => { const user = ctx.get('user'); diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/VideoDownloadDialog.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/VideoDownloadDialog.tsx index 8908a968..5539661d 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/VideoDownloadDialog.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/VideoDownloadDialog.tsx @@ -1,68 +1,313 @@ +import { useEffect, useRef, useState } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Checkbox } from '@/components/ui/checkbox'; +import { Download, Loader2, ChevronLeft, AlertCircle, Check, Music } from 'lucide-react'; +import type { VideoInfo } from '../../../../hooks/useFilesAPI'; import type { UseFileBrowserAppType } from '../useFileBrowserApp'; type VideoDownloadDialogProps = { fileBrowserManager: UseFileBrowserAppType; }; -export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogProps) => { - const { showVideoDownload, setShowVideoDownload, videoUrl, setVideoUrl, audioOnly, setAudioOnly, handleVideoDownload } = - fileBrowserManager; +type DlPhase = 'idle' | 'downloading' | 'saving' | 'done' | 'error'; +type Entry = { + url: string; + status: 'loading' | 'ready' | 'error'; + title?: string; + thumbnail?: string; + duration?: number; + uploader?: string; + error?: string; + dl: DlPhase; + dlError?: string; + filename?: string; +}; - const handleClose = () => { - setShowVideoDownload(false); - setVideoUrl(''); +const fmtDuration = (sec?: number): string => { + if (!sec || sec <= 0) return ''; + const s = Math.round(sec); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const ss = String(s % 60).padStart(2, '0'); + return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`; +}; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +// Keep a subfolder name to a single safe path segment. +const sanitizeFolder = (name: string) => name.replace(/[/\\]/g, '').replace(/^\.+/, '').trim(); + +export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogProps) => { + const { showVideoDownload, setShowVideoDownload, currentPath, refresh, files } = fileBrowserManager; + + const [phase, setPhase] = useState<'input' | 'preview'>('input'); + const [url, setUrl] = useState(''); + const [audioOnly, setAudioOnly] = useState(false); + const [subfolder, setSubfolder] = useState(''); + const [fetching, setFetching] = useState(false); + const [entries, setEntries] = useState([]); + const entriesRef = useRef(entries); + entriesRef.current = entries; + + const isPlaylist = entries.length > 1; + const readyCount = entries.filter((e) => e.status === 'ready').length; + + const reset = () => { + setPhase('input'); + setUrl(''); setAudioOnly(false); + setSubfolder(''); + setFetching(false); + setEntries([]); }; + // Reset whenever the dialog is (re)opened, so a new session starts clean. + useEffect(() => { + if (showVideoDownload) reset(); + }, [showVideoDownload]); + + const handleClose = () => setShowVideoDownload(false); + + const patch = (i: number, p: Partial) => + setEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, ...p } : e))); + + // Fetch metadata: expand a playlist URL to its entries, then prefetch each one's info progressively. + const fetchMeta = async () => { + const u = url.trim(); + if (!u) return; + setFetching(true); + setEntries([]); + + let urls = [u]; + if (u.includes('list=')) { + const pl = await files.videoPlaylist(u).catch(() => null); + if (pl?.error) { + setEntries([{ url: u, status: 'error', error: pl.error, dl: 'idle' }]); + setPhase('preview'); + setFetching(false); + return; + } + if (pl?.urls?.length) urls = pl.urls; + } + + setEntries(urls.map((v) => ({ url: v, status: 'loading', dl: 'idle' }))); + setPhase('preview'); + + // Sequentially (ReClip does yt-dlp per video — parallel would hammer it); cards fill in as they resolve. + for (let i = 0; i < urls.length; i++) { + const info = await files.videoInfo(urls[i]!).catch((): VideoInfo => ({ error: 'Could not fetch info' })); + if (info.error) patch(i, { status: 'error', error: info.error }); + else + patch(i, { + status: 'ready', + title: info.title, + thumbnail: info.thumbnail, + duration: info.duration, + uploader: info.uploader, + }); + } + setFetching(false); + }; + + const targetDir = () => { + const sub = sanitizeFolder(subfolder); + if (!sub) return currentPath; + return currentPath === '/' ? `/${sub}` : `${currentPath}/${sub}`; + }; + + // Download one entry as a background job (the server delegates to ReClip), polling to completion. + const downloadEntry = async (i: number, entryUrl: string) => { + patch(i, { dl: 'downloading', dlError: undefined }); + try { + const { jobId } = await files.downloadVideo(entryUrl, targetDir(), audioOnly); + const deadline = Date.now() + 60 * 60 * 1000; + for (;;) { + if (Date.now() > deadline) return patch(i, { dl: 'error', dlError: 'Timed out' }); + await sleep(2000); + const st = await files.downloadVideoStatus(jobId).catch(() => null); + if (!st) continue; + if (st.status === 'error') return patch(i, { dl: 'error', dlError: st.error || 'Download failed' }); + if (st.status === 'transferring') patch(i, { dl: 'saving' }); + if (st.status === 'done') { + patch(i, { dl: 'done', filename: st.filename }); + void refresh(); + return; + } + } + } catch { + patch(i, { dl: 'error', dlError: 'Could not start the download' }); + } + }; + + const [downloadingAll, setDownloadingAll] = useState(false); + const downloadAll = async () => { + setDownloadingAll(true); + const snapshot = entriesRef.current; + for (let i = 0; i < snapshot.length; i++) { + const e = snapshot[i]!; + if (e.status === 'ready' && e.dl !== 'done' && e.dl !== 'downloading' && e.dl !== 'saving') { + await downloadEntry(i, e.url); + } + } + setDownloadingAll(false); + }; + + const dlLabel = (e: Entry) => + e.dl === 'downloading' ? 'Downloading…' : e.dl === 'saving' ? 'Saving…' : e.dl === 'done' ? 'Saved' : ''; + return ( - { - if (!open) handleClose(); - }} - > - + !open && handleClose()}> + Download video - Download a video from a URL using yt-dlp + + {phase === 'input' + ? 'Paste a video or playlist URL — it fetches details before downloading.' + : 'Review and download.'} + -
{ - ev.preventDefault(); - handleVideoDownload(); - }} - className="flex flex-col gap-4" - > - setVideoUrl(ev.target.value)} - placeholder="https://www.youtube.com/watch?v=..." - className="h-10 w-full text-sm rounded-md border border-duck-dark/20 bg-background/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50 px-3" - /> - -
- - + + {phase === 'input' ? ( + { + ev.preventDefault(); + void fetchMeta(); + }} + className="flex flex-col gap-4" + > + setUrl(ev.target.value)} + placeholder="https://www.youtube.com/watch?v=… or …/playlist?list=…" + className="h-10 w-full rounded-md border border-duck-dark/20 bg-background/60 px-3 text-sm text-duck-dark outline-none placeholder:text-duck-dark/40 focus:border-duck-teal/50" + /> + +
+ + +
+ + ) : ( +
+
+ + + {audioOnly ? 'Audio only' : 'Video'} + {isPlaylist ? ` · ${entries.length} items` : ''} + +
+ + {isPlaylist && ( +
+ setSubfolder(ev.target.value)} + placeholder="Subfolder (optional) — leave blank for this folder" + className="h-9 min-w-0 flex-1 rounded-md border border-duck-dark/20 bg-background/60 px-3 text-sm text-duck-dark outline-none placeholder:text-duck-dark/40 focus:border-duck-teal/50" + /> + +
+ )} + +
+ {entries.map((e, i) => ( +
+
+ {e.status === 'loading' ? ( + + ) : e.status === 'error' ? ( + + ) : e.thumbnail && !audioOnly ? ( + + ) : ( + + )} +
+ +
+ {e.status === 'loading' ? ( +
+
+
+
+ ) : e.status === 'error' ? ( + <> +

Could not fetch

+

{e.error || e.url}

+ + ) : ( + <> +

+ {e.title || e.url} +

+

+ {[e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · ')} +

+ {e.dl === 'error' &&

{e.dlError}

} + + )} +
+ + {e.status === 'ready' && ( +
+ {e.dl === 'done' ? ( + + Saved + + ) : e.dl === 'downloading' || e.dl === 'saving' ? ( + + {dlLabel(e)} + + ) : ( + + )} +
+ )} +
+ ))} +
- + )}
); diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts index 5afde4b3..67d0ff50 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts @@ -55,8 +55,6 @@ export const useFileBrowserApp = ( selectedNames?: string[]; } | null>(null); const [showVideoDownload, setShowVideoDownload] = useState(false); - const [videoUrl, setVideoUrl] = useState(''); - const [audioOnly, setAudioOnly] = useState(false); const [showDictate, setShowDictate] = useState(false); const dragCounter = useRef(0); const { getMatchingTasks, getMatchingTaskGroups } = useTasks(); @@ -479,47 +477,6 @@ export const useFileBrowserApp = ( } }; - const handleVideoDownload = async () => { - const url = videoUrl.trim(); - if (!url) return; - const wasAudio = audioOnly; - const dir = currentPath; - // Close the dialog right away — the download runs as a background job and is tracked via a toast, - // so a large video no longer holds the request open (which was 504-ing behind the reverse proxy). - setShowVideoDownload(false); - setVideoUrl(''); - setAudioOnly(false); - - const toastId = toast.loading(wasAudio ? 'Extracting audio…' : 'Downloading video…'); - try { - const { jobId } = await files.downloadVideo(url, dir, wasAudio); - const deadline = Date.now() + 60 * 60 * 1000; - for (;;) { - if (Date.now() > deadline) { - toast.error('Download timed out', { id: toastId }); - return; - } - await new Promise((r) => setTimeout(r, 2000)); - const st = await files.downloadVideoStatus(jobId).catch(() => null); - if (!st) continue; - if (st.status === 'error') { - toast.error(st.error || 'Download failed', { id: toastId }); - return; - } - if (st.status === 'transferring') { - toast.loading('Saving to folder…', { id: toastId }); - } - if (st.status === 'done') { - toast.success(st.filename ? `Downloaded ${st.filename}` : 'Download complete', { id: toastId }); - await refresh(); - return; - } - } - } catch { - toast.error('Could not start the download', { id: toastId }); - } - }; - const handleCut = () => { const paths = selected.size > 0 ? selectedPaths() : []; if (paths.length === 0) return; @@ -708,13 +665,11 @@ export const useFileBrowserApp = ( setRunningTask, getMatchingTasks, getMatchingTaskGroups, + // Files API (for self-contained dialogs like the video downloader) + files, // Video download showVideoDownload, setShowVideoDownload, - videoUrl, - setVideoUrl, - audioOnly, - setAudioOnly, // Dictate showDictate, setShowDictate, @@ -742,7 +697,6 @@ export const useFileBrowserApp = ( handleExtract, handlePlay, handleGitClone, - handleVideoDownload, handleCut, handleCopy, handlePaste, diff --git a/src/workspaces/officerdev/src/hooks/useFilesAPI.ts b/src/workspaces/officerdev/src/hooks/useFilesAPI.ts index 45b9e7e8..e602e49e 100644 --- a/src/workspaces/officerdev/src/hooks/useFilesAPI.ts +++ b/src/workspaces/officerdev/src/hooks/useFilesAPI.ts @@ -54,6 +54,13 @@ export const useFilesAPI = (root: string = 'home') => { downloadVideoStatus: (jobId: string) => client.get(withRoot(`/file-browser/download-video/${jobId}`)), + // Prefetch one video's metadata (ReClip /api/info via the platform proxy). Returns { error } inline. + videoInfo: (url: string) => client.post(withRoot('/file-browser/video-info'), { url }), + + // Expand a playlist URL into its individual video URLs (ReClip /api/playlist). + videoPlaylist: (url: string) => + client.post<{ urls?: string[]; error?: string }>(withRoot('/file-browser/video-playlist'), { url }), + tts: (path: string, opts?: { saveNextTo?: boolean }) => client.post<{ audioPath: string; audioRoot: string }>('/file-browser/tts', { path, root, ...opts }), @@ -156,6 +163,14 @@ export type DownloadVideoStatus = { filename?: string; }; +export type VideoInfo = { + title?: string; + thumbnail?: string; + duration?: number; + uploader?: string; + error?: string; +}; + export type AudioTrack = { id: number; codec: string;