From c0c40135ec5e451940a240d6daa2696f52df6a0a Mon Sep 17 00:00:00 2001 From: marcusd Date: Wed, 29 Oct 2025 15:06:00 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 217 bytes .../app/__pycache__/main.cpython-312.pyc | Bin 0 -> 18576 bytes download-frontend/app/page.tsx | 487 +++++++++++++++++- 3 files changed, 480 insertions(+), 7 deletions(-) create mode 100644 download-backend/app/__pycache__/__init__.cpython-312.pyc create mode 100644 download-backend/app/__pycache__/main.cpython-312.pyc 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 0000000000000000000000000000000000000000..2c513f9c34b277f2e88e5c61d2ddc5b5652dfc07 GIT binary patch literal 217 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 PUcU8FtA%$n#^Qeglu%ib literal 0 HcmV?d00001 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} +
+
); }