file browser: video download — metadata prefetch + playlist handling
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1328,6 +1328,39 @@ router.get('/download-video/:jobId', (ctx) => {
|
|||||||
return ctx.json({ status: job.status, error: job.error, filename: job.filename });
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
return ctx.json(data);
|
||||||
|
});
|
||||||
|
|
||||||
// Git clone a repository into a directory
|
// Git clone a repository into a directory
|
||||||
router.post('/git-clone', async (ctx) => {
|
router.post('/git-clone', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
|
|||||||
+269
-24
@@ -1,48 +1,187 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
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';
|
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
||||||
|
|
||||||
type VideoDownloadDialogProps = {
|
type VideoDownloadDialogProps = {
|
||||||
fileBrowserManager: UseFileBrowserAppType;
|
fileBrowserManager: UseFileBrowserAppType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogProps) => {
|
type DlPhase = 'idle' | 'downloading' | 'saving' | 'done' | 'error';
|
||||||
const { showVideoDownload, setShowVideoDownload, videoUrl, setVideoUrl, audioOnly, setAudioOnly, handleVideoDownload } =
|
type Entry = {
|
||||||
fileBrowserManager;
|
url: string;
|
||||||
|
status: 'loading' | 'ready' | 'error';
|
||||||
|
title?: string;
|
||||||
|
thumbnail?: string;
|
||||||
|
duration?: number;
|
||||||
|
uploader?: string;
|
||||||
|
error?: string;
|
||||||
|
dl: DlPhase;
|
||||||
|
dlError?: string;
|
||||||
|
filename?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const fmtDuration = (sec?: number): string => {
|
||||||
setShowVideoDownload(false);
|
if (!sec || sec <= 0) return '';
|
||||||
setVideoUrl('');
|
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<Entry[]>([]);
|
||||||
|
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);
|
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<Entry>) =>
|
||||||
|
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 (
|
return (
|
||||||
<Dialog
|
<Dialog open={showVideoDownload} onOpenChange={(open) => !open && handleClose()}>
|
||||||
open={showVideoDownload}
|
<DialogContent className="max-w-lg">
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) handleClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Download video</DialogTitle>
|
<DialogTitle>Download video</DialogTitle>
|
||||||
<DialogDescription>Download a video from a URL using yt-dlp</DialogDescription>
|
<DialogDescription>
|
||||||
|
{phase === 'input'
|
||||||
|
? 'Paste a video or playlist URL — it fetches details before downloading.'
|
||||||
|
: 'Review and download.'}
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
{phase === 'input' ? (
|
||||||
<form
|
<form
|
||||||
onSubmit={(ev) => {
|
onSubmit={(ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
handleVideoDownload();
|
void fetchMeta();
|
||||||
}}
|
}}
|
||||||
className="flex flex-col gap-4"
|
className="flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
value={videoUrl}
|
value={url}
|
||||||
onChange={(ev) => setVideoUrl(ev.target.value)}
|
onChange={(ev) => setUrl(ev.target.value)}
|
||||||
placeholder="https://www.youtube.com/watch?v=..."
|
placeholder="https://www.youtube.com/watch?v=… or …/playlist?list=…"
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
<label className="flex items-center gap-2 cursor-pointer text-sm text-duck-dark/70">
|
<label className="flex cursor-pointer items-center gap-2 text-sm text-duck-dark/70">
|
||||||
<Checkbox checked={audioOnly} onCheckedChange={(v) => setAudioOnly(v === true)} />
|
<Checkbox checked={audioOnly} onCheckedChange={(v) => setAudioOnly(v === true)} />
|
||||||
Extract audio only (mp3)
|
Extract audio only (mp3)
|
||||||
</label>
|
</label>
|
||||||
@@ -50,19 +189,125 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="px-4 py-2 text-sm rounded-md text-duck-dark/70 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
className="cursor-pointer rounded-md px-4 py-2 text-sm text-duck-dark/70 transition-colors hover:bg-duck-dark/5"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!videoUrl.trim()}
|
disabled={!url.trim() || fetching}
|
||||||
className="px-4 py-2 text-sm rounded-md bg-duck-teal text-duck-yellow hover:bg-duck-teal/90 cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
className="flex cursor-pointer items-center gap-2 rounded-md bg-duck-teal px-4 py-2 text-sm text-duck-yellow transition-colors hover:bg-duck-teal/90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
>
|
>
|
||||||
Download
|
{fetching && <Loader2 size={14} className="animate-spin" />}
|
||||||
|
Fetch
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPhase('input')}
|
||||||
|
className="flex cursor-pointer items-center gap-1 text-sm text-duck-dark/60 hover:text-duck-dark"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={15} /> Back
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-duck-dark/50">
|
||||||
|
{audioOnly ? 'Audio only' : 'Video'}
|
||||||
|
{isPlaylist ? ` · ${entries.length} items` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPlaylist && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={subfolder}
|
||||||
|
onChange={(ev) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void downloadAll()}
|
||||||
|
disabled={downloadingAll || readyCount === 0}
|
||||||
|
className="flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md bg-duck-teal px-3 py-2 text-sm text-duck-yellow transition-colors hover:bg-duck-teal/90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{downloadingAll ? <Loader2 size={14} className="animate-spin" /> : <Download size={14} />}
|
||||||
|
Download all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex max-h-[52vh] flex-col gap-2 overflow-y-auto pr-1">
|
||||||
|
{entries.map((e, i) => (
|
||||||
|
<div
|
||||||
|
key={`${e.url}-${i}`}
|
||||||
|
className="flex items-center gap-3 rounded-md border border-duck-dark/10 bg-background/40 p-2"
|
||||||
|
>
|
||||||
|
<div className="flex h-12 w-20 shrink-0 items-center justify-center overflow-hidden rounded bg-duck-dark/10">
|
||||||
|
{e.status === 'loading' ? (
|
||||||
|
<Loader2 size={16} className="animate-spin text-duck-dark/40" />
|
||||||
|
) : e.status === 'error' ? (
|
||||||
|
<AlertCircle size={16} className="text-red-500/70" />
|
||||||
|
) : e.thumbnail && !audioOnly ? (
|
||||||
|
<img src={e.thumbnail} alt="" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<Music size={16} className="text-duck-dark/40" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{e.status === 'loading' ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="h-3 w-3/4 rounded bg-duck-dark/10" />
|
||||||
|
<div className="h-2.5 w-1/3 rounded bg-duck-dark/10" />
|
||||||
|
</div>
|
||||||
|
) : e.status === 'error' ? (
|
||||||
|
<>
|
||||||
|
<p className="truncate text-sm font-medium text-red-500">Could not fetch</p>
|
||||||
|
<p className="truncate text-xs text-duck-dark/50">{e.error || e.url}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="truncate text-sm font-medium text-duck-dark" title={e.title}>
|
||||||
|
{e.title || e.url}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-duck-dark/50">
|
||||||
|
{[e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · ')}
|
||||||
|
</p>
|
||||||
|
{e.dl === 'error' && <p className="truncate text-xs text-red-500">{e.dlError}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{e.status === 'ready' && (
|
||||||
|
<div className="shrink-0">
|
||||||
|
{e.dl === 'done' ? (
|
||||||
|
<span className="flex items-center gap-1 text-xs font-medium text-duck-teal">
|
||||||
|
<Check size={14} /> Saved
|
||||||
|
</span>
|
||||||
|
) : e.dl === 'downloading' || e.dl === 'saving' ? (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-duck-dark/60">
|
||||||
|
<Loader2 size={13} className="animate-spin" /> {dlLabel(e)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void downloadEntry(i, e.url)}
|
||||||
|
className="flex cursor-pointer items-center gap-1.5 rounded-md border border-duck-teal/40 px-2.5 py-1.5 text-xs font-medium text-duck-teal transition-colors hover:bg-duck-teal/10"
|
||||||
|
>
|
||||||
|
<Download size={13} />
|
||||||
|
{e.dl === 'error' ? 'Retry' : 'Download'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -55,8 +55,6 @@ export const useFileBrowserApp = (
|
|||||||
selectedNames?: string[];
|
selectedNames?: string[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [showVideoDownload, setShowVideoDownload] = useState(false);
|
const [showVideoDownload, setShowVideoDownload] = useState(false);
|
||||||
const [videoUrl, setVideoUrl] = useState('');
|
|
||||||
const [audioOnly, setAudioOnly] = useState(false);
|
|
||||||
const [showDictate, setShowDictate] = useState(false);
|
const [showDictate, setShowDictate] = useState(false);
|
||||||
const dragCounter = useRef(0);
|
const dragCounter = useRef(0);
|
||||||
const { getMatchingTasks, getMatchingTaskGroups } = useTasks();
|
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 handleCut = () => {
|
||||||
const paths = selected.size > 0 ? selectedPaths() : [];
|
const paths = selected.size > 0 ? selectedPaths() : [];
|
||||||
if (paths.length === 0) return;
|
if (paths.length === 0) return;
|
||||||
@@ -708,13 +665,11 @@ export const useFileBrowserApp = (
|
|||||||
setRunningTask,
|
setRunningTask,
|
||||||
getMatchingTasks,
|
getMatchingTasks,
|
||||||
getMatchingTaskGroups,
|
getMatchingTaskGroups,
|
||||||
|
// Files API (for self-contained dialogs like the video downloader)
|
||||||
|
files,
|
||||||
// Video download
|
// Video download
|
||||||
showVideoDownload,
|
showVideoDownload,
|
||||||
setShowVideoDownload,
|
setShowVideoDownload,
|
||||||
videoUrl,
|
|
||||||
setVideoUrl,
|
|
||||||
audioOnly,
|
|
||||||
setAudioOnly,
|
|
||||||
// Dictate
|
// Dictate
|
||||||
showDictate,
|
showDictate,
|
||||||
setShowDictate,
|
setShowDictate,
|
||||||
@@ -742,7 +697,6 @@ export const useFileBrowserApp = (
|
|||||||
handleExtract,
|
handleExtract,
|
||||||
handlePlay,
|
handlePlay,
|
||||||
handleGitClone,
|
handleGitClone,
|
||||||
handleVideoDownload,
|
|
||||||
handleCut,
|
handleCut,
|
||||||
handleCopy,
|
handleCopy,
|
||||||
handlePaste,
|
handlePaste,
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ export const useFilesAPI = (root: string = 'home') => {
|
|||||||
downloadVideoStatus: (jobId: string) =>
|
downloadVideoStatus: (jobId: string) =>
|
||||||
client.get<DownloadVideoStatus>(withRoot(`/file-browser/download-video/${jobId}`)),
|
client.get<DownloadVideoStatus>(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<VideoInfo>(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 }) =>
|
tts: (path: string, opts?: { saveNextTo?: boolean }) =>
|
||||||
client.post<{ audioPath: string; audioRoot: string }>('/file-browser/tts', { path, root, ...opts }),
|
client.post<{ audioPath: string; audioRoot: string }>('/file-browser/tts', { path, root, ...opts }),
|
||||||
|
|
||||||
@@ -156,6 +163,14 @@ export type DownloadVideoStatus = {
|
|||||||
filename?: string;
|
filename?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type VideoInfo = {
|
||||||
|
title?: string;
|
||||||
|
thumbnail?: string;
|
||||||
|
duration?: number;
|
||||||
|
uploader?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type AudioTrack = {
|
export type AudioTrack = {
|
||||||
id: number;
|
id: number;
|
||||||
codec: string;
|
codec: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user