diff --git a/download-backend/app/__pycache__/__init__.cpython-312.pyc b/download-backend/app/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..2c513f9 Binary files /dev/null and b/download-backend/app/__pycache__/__init__.cpython-312.pyc differ diff --git a/download-backend/app/__pycache__/main.cpython-312.pyc b/download-backend/app/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000..897ee1a Binary files /dev/null and b/download-backend/app/__pycache__/main.cpython-312.pyc differ diff --git a/download-frontend/app/page.tsx b/download-frontend/app/page.tsx index efdb79c..7d0f2a4 100644 --- a/download-frontend/app/page.tsx +++ b/download-frontend/app/page.tsx @@ -1,12 +1,485 @@ -import Image from "next/image"; +/* 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(null); + const [infoState, setInfoState] = useState< + "idle" | "loading" | "success" | "error" + >("idle"); + const [infoError, setInfoError] = useState(null); + const [downloads, setDownloads] = useState>({}); + + const baseDownloadState = useMemo( + () => ({ 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); + } + }; + + const triggerBrowserDownload = async ( + downloadUrl: string, + filename?: string + ) => { + try { + const response = await fetch(downloadUrl, { + mode: "cors", + credentials: "omit", + }); + if (!response.ok) { + throw new Error(`文件获取失败,状态码 ${response.status.toString()}`); + } + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.download = filename ?? "download"; + anchor.rel = "noopener"; + anchor.style.display = "none"; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(objectUrl); + } catch (error) { + const message = + error instanceof Error ? error.message : "无法保存文件,请重试。"; + // 仅在控制台输出,避免覆盖成功状态。用户可重新触发下载。 + console.error("[download]", message); + } + }; + return ( -
-
-

YouTube Video Downloader

- -
-
+
+
+
+
+ + Video Fetcher + +

+ 高可用视频下载服务 +

+

+ 输入任意公共视频链接。系统将列出可用清晰度与格式,支持一键最佳质量下载或按需选择单独下载。 +

+
+ +
+ + +
+ + {infoState === "error" && infoError ? ( +

+ {infoError} +

+ ) : null} +
+ + {infoState === "loading" ? ( +
+ 正在拉取视频信息,请稍候… +
+ ) : null} + + {videoInfo ? ( +
+
+ {videoInfo.thumbnail ? ( + // eslint-disable-next-line @next/next/no-img-element + {videoInfo.title} + ) : null} + +
+
+

+ {videoInfo.title} +

+
+ {videoInfo.uploader ? ( + + 发布者 · {videoInfo.uploader} + + ) : null} + {formatDuration(videoInfo.duration) ? ( + + 时长 · {formatDuration(videoInfo.duration)} + + ) : null} + + 可用格式 · {sortedFormats.length} + +
+
+ +
+
+ + 一键最佳质量下载 + + 自动采用服务推荐的最佳格式,适合快速下载。 + {downloads[DEFAULT_DOWNLOAD_KEY]?.status === "success" ? ( + + ) : null} + {downloads[DEFAULT_DOWNLOAD_KEY]?.status === "error" ? ( + + {downloads[DEFAULT_DOWNLOAD_KEY].message} + + ) : null} +
+ +
+
+
+ +
+
+

+ 可选格式列表 +

+ + 支持逐项下载,适合精准控制清晰度或格式。 + +
+ +
+ + + + + + + + + + + + {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 ( + + + + + + + + ); + })} + +
格式 ID分辨率 / FPS编码体积操作
+
+ {format.format_id} + {format.ext ? ( + + {format.ext} + + ) : null} +
+
+
+ {resolution} + {fps ? ( + + {fps} + + ) : null} +
+
+
+ 视频 · {videoCodec} + 音频 · {audioCodec} +
+
+ {formatFileSize(format.filesize ?? format.filesize_approx)} + +
+ + {downloadState.status === "success" ? ( + + ) : null} + {downloadState.status === "error" ? ( + + {downloadState.message} + + ) : null} +
+
+
+
+
+ ) : null} +
+
); }