Files
2025-11-03 15:07:20 +08:00

447 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* eslint-disable react-hooks/exhaustive-deps */
"use client";
import { useMemo, useState } from "react";
type FormatInfo = {
format_id: string;
ext?: string | null;
resolution?: string | null;
fps?: number | null;
vcodec?: string | null;
acodec?: string | null;
filesize?: number | null;
filesize_approx?: number | null;
};
type VideoInfo = {
id: string;
title: string;
duration?: number | null;
uploader?: string | null;
thumbnail?: string | null;
webpage_url?: string | null;
formats: FormatInfo[];
};
type DownloadResponse = {
file_name: string;
download_url: string;
};
type DownloadState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; url: string; file: string }
| { status: "error"; message: string };
const API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
const DEFAULT_DOWNLOAD_KEY = "__default";
function formatFileSize(bytes?: number | null) {
if (!bytes || bytes <= 0) return "未知";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
function formatDuration(seconds?: number | null) {
if (!seconds || seconds <= 0) return null;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) {
return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
return `${m}:${String(s).padStart(2, "0")}`;
}
export default function Home() {
const [url, setUrl] = useState("");
const [videoInfo, setVideoInfo] = useState<VideoInfo | null>(null);
const [infoState, setInfoState] = useState<
"idle" | "loading" | "success" | "error"
>("idle");
const [infoError, setInfoError] = useState<string | null>(null);
const [downloads, setDownloads] = useState<Record<string, DownloadState>>({});
const baseDownloadState = useMemo<DownloadState>(
() => ({ status: "idle" }),
[]
);
const sortedFormats = useMemo(() => {
if (!videoInfo) return [];
if (!videoInfo.formats?.length) return [];
const byQuality = [...videoInfo.formats].sort((a, b) => {
const sizeA = a.filesize ?? a.filesize_approx ?? 0;
const sizeB = b.filesize ?? b.filesize_approx ?? 0;
if (sizeA !== sizeB) return sizeB - sizeA;
const resA = (a.resolution ?? "").match(/\d+/g)?.map(Number) ?? [0, 0];
const resB = (b.resolution ?? "").match(/\d+/g)?.map(Number) ?? [0, 0];
if (resA[0] !== resB[0]) return resB[0] - resA[0];
if (resA[1] !== resB[1]) return resB[1] - resA[1];
return (b.fps ?? 0) - (a.fps ?? 0);
});
return byQuality.filter(
(format, index, array) =>
array.findIndex((item) => item.format_id === format.format_id) === index
);
}, [videoInfo]);
const handleFetchInfo = async (event: React.FormEvent) => {
event.preventDefault();
if (!url.trim()) {
setInfoError("请输入有效的视频链接。");
setInfoState("error");
return;
}
try {
setInfoState("loading");
setInfoError(null);
setVideoInfo(null);
setDownloads({});
const response = await fetch(
`${API_BASE}/api/info?url=${encodeURIComponent(url.trim())}`
);
if (!response.ok) {
const detail = await response.text();
throw new Error(
detail || `获取失败,状态码 ${response.status.toString()}`
);
}
const info = (await response.json()) as VideoInfo;
setVideoInfo(info);
setInfoState("success");
} catch (error) {
const message =
error instanceof Error ? error.message : "获取视频信息失败。";
setInfoError(message);
setInfoState("error");
}
};
const startDownload = (formatId: string | null) => {
const key = formatId ?? DEFAULT_DOWNLOAD_KEY;
setDownloads((prev) => ({
...prev,
[key]: { status: "loading" },
}));
};
const finishDownload = (
formatId: string | null,
response: DownloadResponse
) => {
const key = formatId ?? DEFAULT_DOWNLOAD_KEY;
setDownloads((prev) => ({
...prev,
[key]: {
status: "success",
url: response.download_url,
file: response.file_name,
},
}));
};
const failDownload = (formatId: string | null, message: string) => {
const key = formatId ?? DEFAULT_DOWNLOAD_KEY;
setDownloads((prev) => ({
...prev,
[key]: {
status: "error",
message,
},
}));
};
const handleDownload = async (formatId: string | null) => {
if (!url.trim()) {
failDownload(formatId, "请先输入视频链接。");
return;
}
try {
startDownload(formatId);
const response = await fetch(`${API_BASE}/api/download`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
url: url.trim(),
format_id: formatId ?? undefined,
}),
});
if (!response.ok) {
const detail = await response.text();
throw new Error(
detail || `下载失败,状态码 ${response.status.toString()}`
);
}
const payload = (await response.json()) as DownloadResponse;
finishDownload(formatId, payload);
} catch (error) {
const message =
error instanceof Error ? error.message : "下载失败,请稍后再试。";
failDownload(formatId, message);
}
};
return (
<main className="min-h-screen bg-gradient-to-br from-zinc-50 via-white to-zinc-100 py-16 px-4 font-sans text-zinc-900 dark:from-black dark:via-zinc-950 dark:to-black dark:text-zinc-100">
<div className="mx-auto flex w-full max-w-4xl flex-col gap-8">
<header className="flex flex-col gap-4 rounded-3xl border border-zinc-200 bg-white/70 px-8 py-10 shadow-lg backdrop-blur dark:border-zinc-800 dark:bg-zinc-950/70">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium uppercase tracking-[0.35em] text-zinc-500 dark:text-zinc-400">
Video Fetcher
</span>
<h1 className="text-3xl font-semibold tracking-tight md:text-4xl">
YTB视频下载服务
</h1>
<p className="max-w-2xl text-base text-zinc-600 dark:text-zinc-400">
</p>
</div>
<form
onSubmit={handleFetchInfo}
className="flex flex-col gap-3 rounded-2xl border border-zinc-200/80 bg-white/80 p-4 shadow-inner dark:border-zinc-800/80 dark:bg-zinc-950/80 md:flex-row md:items-center"
>
<label className="flex-1">
<span className="sr-only"></span>
<input
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="https://www.youtube.com/watch?v=..."
className="w-full rounded-xl border border-zinc-200 bg-white px-4 py-3 text-base outline-none ring-0 transition focus:border-zinc-400 focus:shadow-lg focus:shadow-zinc-200/70 dark:border-zinc-700 dark:bg-black dark:focus:border-zinc-500 dark:focus:shadow-zinc-800/70"
autoComplete="off"
/>
</label>
<button
type="submit"
className="flex w-full items-center justify-center rounded-xl bg-zinc-900 px-5 py-3 text-sm font-medium uppercase tracking-widest text-white transition hover:bg-zinc-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900 dark:bg-zinc-200 dark:text-zinc-950 dark:hover:bg-white md:w-auto"
disabled={infoState === "loading"}
>
{infoState === "loading" ? "获取中…" : "获取信息"}
</button>
</form>
{infoState === "error" && infoError ? (
<p className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600 dark:border-red-900 dark:bg-red-950/50 dark:text-red-400">
{infoError}
</p>
) : null}
</header>
{infoState === "loading" ? (
<section className="rounded-3xl border border-zinc-200 bg-white/70 p-8 text-sm text-zinc-600 shadow-lg dark:border-zinc-800 dark:bg-zinc-950/70 dark:text-zinc-400">
</section>
) : null}
{videoInfo ? (
<section className="flex flex-col gap-6 rounded-3xl border border-zinc-200 bg-white/80 p-8 shadow-xl backdrop-blur-md dark:border-zinc-800 dark:bg-zinc-950/80">
<div className="flex flex-col gap-6 md:flex-row md:items-start md:gap-8">
{videoInfo.thumbnail ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={videoInfo.thumbnail}
alt={videoInfo.title}
className="aspect-video w-full max-w-sm rounded-2xl border border-zinc-200 object-cover shadow-md dark:border-zinc-800"
/>
) : null}
<div className="flex flex-1 flex-col gap-4">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
{videoInfo.title}
</h2>
<div className="mt-2 flex flex-wrap gap-3 text-sm text-zinc-500 dark:text-zinc-400">
{videoInfo.uploader ? (
<span className="rounded-full border border-zinc-200 px-3 py-1 dark:border-zinc-700">
· {videoInfo.uploader}
</span>
) : null}
{formatDuration(videoInfo.duration) ? (
<span className="rounded-full border border-zinc-200 px-3 py-1 dark:border-zinc-700">
· {formatDuration(videoInfo.duration)}
</span>
) : null}
<span className="rounded-full border border-zinc-200 px-3 py-1 dark:border-zinc-700">
· {sortedFormats.length}
</span>
</div>
</div>
<div className="flex flex-col gap-3 rounded-2xl border border-zinc-200/80 bg-zinc-50/80 p-4 dark:border-zinc-800/80 dark:bg-black/50 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-1 text-sm text-zinc-600 dark:text-zinc-400">
<span className="font-medium text-zinc-800 dark:text-zinc-200">
</span>
<span></span>
{downloads[DEFAULT_DOWNLOAD_KEY]?.status === "success" ? (
<a
href={downloads[DEFAULT_DOWNLOAD_KEY].url}
target="_blank"
rel="noopener"
className="w-fit truncate text-left text-sm font-medium text-zinc-900 underline underline-offset-4 transition hover:text-zinc-700 dark:text-zinc-200 dark:hover:text-zinc-50"
>
{downloads[DEFAULT_DOWNLOAD_KEY].file}
</a>
) : null}
{downloads[DEFAULT_DOWNLOAD_KEY]?.status === "error" ? (
<span className="text-sm text-red-500">
{downloads[DEFAULT_DOWNLOAD_KEY].message}
</span>
) : null}
</div>
<button
type="button"
onClick={() => handleDownload(null)}
className="w-full rounded-xl bg-zinc-900 px-5 py-3 text-sm font-semibold text-white transition hover:bg-zinc-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900 dark:bg-zinc-200 dark:text-zinc-950 dark:hover:bg-white md:w-auto"
disabled={downloads[DEFAULT_DOWNLOAD_KEY]?.status === "loading"}
>
{downloads[DEFAULT_DOWNLOAD_KEY]?.status === "loading"
? "下载中…"
: "立即下载"}
</button>
</div>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold tracking-tight">
</h3>
<span className="text-sm text-zinc-500 dark:text-zinc-400">
</span>
</div>
<div className="overflow-hidden rounded-2xl border border-zinc-200 shadow-sm dark:border-zinc-800">
<table className="min-w-full border-collapse">
<thead className="bg-zinc-50 text-left text-xs uppercase tracking-wider text-zinc-500 dark:bg-zinc-900 dark:text-zinc-400">
<tr>
<th className="px-4 py-3 font-medium"> ID</th>
<th className="px-4 py-3 font-medium"> / FPS</th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 text-sm dark:divide-zinc-800">
{sortedFormats.map((format) => {
const key = format.format_id;
const downloadState = downloads[key] ?? baseDownloadState;
const videoCodec =
format.vcodec && format.vcodec !== "none"
? format.vcodec.split(".")[0]
: "无";
const audioCodec =
format.acodec && format.acodec !== "none"
? format.acodec.split(".")[0]
: "无";
const resolution = format.resolution ?? "未知";
const fps =
format.fps && format.fps > 0
? `${format.fps.toFixed(
Number.isInteger(format.fps) ? 0 : 1
)} FPS`
: null;
return (
<tr
key={format.format_id}
className="bg-white transition hover:bg-zinc-50/70 dark:bg-zinc-950 dark:hover:bg-zinc-900/60"
>
<td className="px-4 py-4 font-medium text-zinc-800 dark:text-zinc-100">
<div className="flex flex-col">
<span>{format.format_id}</span>
{format.ext ? (
<span className="text-xs uppercase text-zinc-400">
{format.ext}
</span>
) : null}
</div>
</td>
<td className="px-4 py-4 text-zinc-600 dark:text-zinc-300">
<div className="flex flex-col gap-1">
<span>{resolution}</span>
{fps ? (
<span className="text-xs text-zinc-400">
{fps}
</span>
) : null}
</div>
</td>
<td className="px-4 py-4 text-zinc-600 dark:text-zinc-300">
<div className="flex flex-col gap-1">
<span> · {videoCodec}</span>
<span> · {audioCodec}</span>
</div>
</td>
<td className="px-4 py-4 text-zinc-600 dark:text-zinc-300">
{formatFileSize(format.filesize ?? format.filesize_approx)}
</td>
<td className="px-4 py-4">
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => handleDownload(format.format_id)}
className="w-full rounded-lg bg-zinc-900 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-white transition hover:bg-zinc-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900 dark:bg-zinc-200 dark:text-zinc-950 dark:hover:bg-white"
disabled={downloadState.status === "loading"}
>
{downloadState.status === "loading"
? "下载中…"
: "下载"}
</button>
{downloadState.status === "success" ? (
<a
href={downloadState.url}
target="_blank"
rel="noopener"
className="truncate text-left text-xs font-medium text-zinc-900 underline underline-offset-4 transition hover:text-zinc-700 dark:text-zinc-200 dark:hover:text-zinc-50"
>
{downloadState.file}
</a>
) : null}
{downloadState.status === "error" ? (
<span className="text-xs text-red-500">
{downloadState.message}
</span>
) : null}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</section>
) : null}
</div>
</main>
);
}