This commit is contained in:
2025-11-06 01:40:00 +01:00
parent de5f54f4bc
commit 602c5a40f1
108 changed files with 9859 additions and 1382 deletions

View File

@@ -0,0 +1,206 @@
import { useEffect, useMemo, useState } from "react";
import {
fetchInfo,
downloadImmediate,
FORMAT_EXTS,
type InfoResponse,
parseContentDispositionFilename,
} from "../../api/apps/Downloader";
export default function Downloader() {
const [url, setUrl] = useState("");
const [probing, setProbing] = useState(false);
const [downloading, setDownloading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<InfoResponse | null>(null);
const [ext, setExt] = useState<typeof FORMAT_EXTS[number]>("mp4");
const [videoRes, setVideoRes] = useState<string | undefined>(undefined);
const [audioRes, setAudioRes] = useState<string | undefined>(undefined);
useEffect(() => {
if (info?.video_resolutions?.length && !videoRes) {
setVideoRes(info.video_resolutions[0]);
}
if (info?.audio_resolutions?.length && !audioRes) {
setAudioRes(info.audio_resolutions[0]);
}
}, [info]);
async function onProbe(e: React.FormEvent) {
e.preventDefault();
setError(null);
setInfo(null);
setProbing(true);
try {
const res = await fetchInfo(url);
setInfo(res);
// reset selections from fresh info
setVideoRes(res.video_resolutions?.[0]);
setAudioRes(res.audio_resolutions?.[0]);
} catch (e: any) {
setError(
e?.response?.data?.error ||
e?.response?.data?.detail ||
e?.message ||
"Failed to get info."
);
} finally {
setProbing(false);
}
}
async function onDownload() {
setError(null);
setDownloading(true);
try {
const { blob, filename } = await downloadImmediate({
url,
ext,
videoResolution: videoRes,
audioResolution: audioRes,
});
const name = filename || parseContentDispositionFilename("") || `download.${ext}`;
const href = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = href;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(href);
} catch (e: any) {
setError(
e?.response?.data?.error ||
e?.response?.data?.detail ||
e?.message ||
"Download failed."
);
} finally {
setDownloading(false);
}
}
const canDownload = useMemo(
() => !!url && !!ext && !!videoRes && !!audioRes,
[url, ext, videoRes, audioRes]
);
return (
<div className="max-w-3xl mx-auto p-4 space-y-4">
<h1 className="text-2xl font-semibold">Downloader</h1>
{error && <div className="rounded border border-red-300 bg-red-50 text-red-700 p-2">{error}</div>}
<form onSubmit={onProbe} className="grid gap-3">
<label className="grid gap-1">
<span className="text-sm font-medium">URL</span>
<input
type="url"
required
placeholder="https://example.com/video"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="w-full border rounded p-2"
/>
</label>
<div className="flex gap-2">
<button
type="submit"
disabled={!url || probing}
className="px-3 py-2 rounded bg-blue-600 text-white disabled:opacity-50"
>
{probing ? "Probing..." : "Get info"}
</button>
<button
type="button"
onClick={onDownload}
disabled={!canDownload || downloading}
className="px-3 py-2 rounded bg-emerald-600 text-white disabled:opacity-50"
>
{downloading ? "Downloading..." : "Download"}
</button>
</div>
</form>
{info && (
<div className="space-y-3">
<div className="flex items-start gap-3">
{info.thumbnail && (
<img
src={info.thumbnail}
alt={info.title || "thumbnail"}
className="w-40 h-24 object-cover rounded border"
/>
)}
<div className="text-sm text-gray-800 space-y-1">
<div>
<span className="font-medium">Title:</span> {info.title || "-"}
</div>
<div>
<span className="font-medium">Duration:</span>{" "}
{info.duration ? `${Math.round(info.duration)} s` : "-"}
</div>
</div>
</div>
<div className="grid md:grid-cols-3 gap-3">
<label className="grid gap-1">
<span className="text-sm font-medium">Container</span>
<select
value={ext}
onChange={(e) => setExt(e.target.value as any)}
className="border rounded p-2"
>
{FORMAT_EXTS.map((x) => (
<option key={x} value={x}>
{x.toUpperCase()}
</option>
))}
</select>
</label>
<label className="grid gap-1">
<span className="text-sm font-medium">Video resolution</span>
<select
value={videoRes || ""}
onChange={(e) => setVideoRes(e.target.value || undefined)}
className="border rounded p-2"
>
{info.video_resolutions?.length ? (
info.video_resolutions.map((r) => (
<option key={r} value={r}>
{r}
</option>
))
) : (
<option value="">-</option>
)}
</select>
</label>
<label className="grid gap-1">
<span className="text-sm font-medium">Audio bitrate</span>
<select
value={audioRes || ""}
onChange={(e) => setAudioRes(e.target.value || undefined)}
className="border rounded p-2"
>
{info.audio_resolutions?.length ? (
info.audio_resolutions.map((r) => (
<option key={r} value={r}>
{r}
</option>
))
) : (
<option value="">-</option>
)}
</select>
</label>
</div>
</div>
)}
</div>
);
}