yt-dlp downloads
This commit is contained in:
@@ -18,9 +18,12 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Upload,
|
Upload,
|
||||||
|
Download,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getIcon } from 'material-file-icons';
|
import { getIcon } from 'material-file-icons';
|
||||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
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 { useFiles, type DirEntry, useTasks, type TaskSummary, Breadcrumb, Toolbar } from 'apps/FileBrowser';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useUserState } from '@/state/useUserState';
|
import { useUserState } from '@/state/useUserState';
|
||||||
@@ -61,6 +64,9 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
const [cloning, setCloning] = useState(false);
|
const [cloning, setCloning] = useState(false);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const [runningTask, setRunningTask] = useState<{ task: TaskSummary; entry: DirEntry } | null>(null);
|
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 dragCounter = useRef(0);
|
||||||
const { getMatchingTasks } = useTasks();
|
const { getMatchingTasks } = useTasks();
|
||||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
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 handleCut = () => {
|
||||||
const paths = selected.size > 0 ? selectedPaths() : [];
|
const paths = selected.size > 0 ? selectedPaths() : [];
|
||||||
if (paths.length === 0) return;
|
if (paths.length === 0) return;
|
||||||
@@ -908,6 +930,10 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
<LayoutGrid className="mr-2 h-4 w-4" />
|
<LayoutGrid className="mr-2 h-4 w-4" />
|
||||||
Create Workspace here
|
Create Workspace here
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={() => setShowVideoDownload(true)} className="cursor-pointer">
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Download video
|
||||||
|
</ContextMenuItem>
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
)}
|
)}
|
||||||
@@ -929,6 +955,63 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
cwd={{ root: homeRoot, path: currentPath.replace(/^\//, '') }}
|
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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -690,6 +690,30 @@ router.post('/move', async (ctx) => {
|
|||||||
return ctx.json({ ok: true, results });
|
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
|
// 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');
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ export const useFiles = (root: string = 'home') => {
|
|||||||
|
|
||||||
gitClone: (url: string, path: string) => client.post(withRoot('/file-browser/git-clone'), { url, path }),
|
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[]) => {
|
download: async (paths: string[]) => {
|
||||||
if (paths.length === 1) {
|
if (paths.length === 1) {
|
||||||
const blob = await client.getBlob(withRoot(`/file-browser/download?path=${encodeURIComponent(paths[0]!)}`));
|
const blob = await client.getBlob(withRoot(`/file-browser/download?path=${encodeURIComponent(paths[0]!)}`));
|
||||||
|
|||||||
Reference in New Issue
Block a user