yt-dlp downloads

This commit is contained in:
2026-02-20 05:28:26 +00:00
parent 2a7485add2
commit 8a637c7788
3 changed files with 110 additions and 0 deletions
@@ -18,9 +18,12 @@ import {
Eye,
EyeOff,
Upload,
Download,
} from 'lucide-react';
import { getIcon } from 'material-file-icons';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { useFiles, type DirEntry, useTasks, type TaskSummary, Breadcrumb, Toolbar } from 'apps/FileBrowser';
import { useClient } from 'hooks/useClient';
import { useUserState } from '@/state/useUserState';
@@ -61,6 +64,9 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
const [cloning, setCloning] = useState(false);
const [dragging, setDragging] = useState(false);
const [runningTask, setRunningTask] = useState<{ task: TaskSummary; entry: DirEntry } | null>(null);
const [showVideoDownload, setShowVideoDownload] = useState(false);
const [videoUrl, setVideoUrl] = useState('');
const [audioOnly, setAudioOnly] = useState(false);
const dragCounter = useRef(0);
const { getMatchingTasks } = useTasks();
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -366,6 +372,22 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
}
};
const handleVideoDownload = async () => {
const url = videoUrl.trim();
if (!url) return;
const toastId = toast.loading(audioOnly ? 'Extracting audio...' : 'Downloading video...');
try {
await files.downloadVideo(url, currentPath, audioOnly);
toast.success('Download complete', { id: toastId });
setShowVideoDownload(false);
setVideoUrl('');
setAudioOnly(false);
await refresh();
} catch {
toast.error('Download failed', { id: toastId });
}
};
const handleCut = () => {
const paths = selected.size > 0 ? selectedPaths() : [];
if (paths.length === 0) return;
@@ -908,6 +930,10 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
<LayoutGrid className="mr-2 h-4 w-4" />
Create Workspace here
</ContextMenuItem>
<ContextMenuItem onClick={() => setShowVideoDownload(true)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Download video
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
@@ -929,6 +955,63 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
cwd={{ root: homeRoot, path: currentPath.replace(/^\//, '') }}
/>
)}
<Dialog
open={showVideoDownload}
onOpenChange={(open) => {
if (!open) {
setShowVideoDownload(false);
setVideoUrl('');
setAudioOnly(false);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Download video</DialogTitle>
<DialogDescription>Download a video from a URL using yt-dlp</DialogDescription>
</DialogHeader>
<form
onSubmit={(ev) => {
ev.preventDefault();
handleVideoDownload();
}}
className="flex flex-col gap-4"
>
<input
autoFocus
value={videoUrl}
onChange={(ev) => 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"
/>
<label className="flex items-center gap-2 cursor-pointer text-sm text-duck-dark/70">
<Checkbox checked={audioOnly} onCheckedChange={(v) => setAudioOnly(v === true)} />
Extract audio only (mp3)
</label>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setShowVideoDownload(false);
setVideoUrl('');
setAudioOnly(false);
}}
className="px-4 py-2 text-sm rounded-md text-duck-dark/70 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!videoUrl.trim()}
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"
>
Download
</button>
</div>
</form>
</DialogContent>
</Dialog>
</>
);
};
+24
View File
@@ -690,6 +690,30 @@ router.post('/move', async (ctx) => {
return ctx.json({ ok: true, results });
});
// Download video via yt-dlp
router.post('/download-video', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const { url, path, audioOnly } = ctx.get('body') as { url: string; path: string; audioOnly?: boolean };
if (!url) throw errors.BAD_REQUEST('url is required');
if (!path) throw errors.BAD_REQUEST('path is required');
const absPath = resolveUserPath(rootDir, path);
const args = ['yt-dlp', '-o', '%(title)s.%(ext)s'];
if (audioOnly) args.push('-x', '--audio-format', 'mp3');
args.push(url);
const proc = Bun.spawn(args, { cwd: absPath, stdout: 'pipe', stderr: 'pipe' });
const exitCode = await proc.exited;
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
throw errors.BAD_REQUEST(stderr.trim() || 'yt-dlp download failed');
}
return ctx.json({ ok: true });
});
// Git clone a repository into a directory
router.post('/git-clone', async (ctx) => {
const user = ctx.get('user');
@@ -68,6 +68,9 @@ export const useFiles = (root: string = 'home') => {
gitClone: (url: string, path: string) => client.post(withRoot('/file-browser/git-clone'), { url, path }),
downloadVideo: (url: string, path: string, audioOnly: boolean) =>
client.post(withRoot('/file-browser/download-video'), { url, path, audioOnly }),
download: async (paths: string[]) => {
if (paths.length === 1) {
const blob = await client.getBlob(withRoot(`/file-browser/download?path=${encodeURIComponent(paths[0]!)}`));