Compare commits

...

4 Commits

Author SHA1 Message Date
09f6a0176a 修改一些bug 2025-11-03 15:07:20 +08:00
d1a1a563c2 chore: ignore tmp_downloads directory to prevent large files 2025-10-29 15:26:44 +08:00
ab9fa1ad7a chore: ignore tmp_downloads directory to prevent large files 2025-10-29 15:25:33 +08:00
c0c40135ec 前端修改 2025-10-29 15:06:00 +08:00
5 changed files with 515 additions and 17 deletions

21
.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# Python virtual environments
/.venv/
**/.venv/
download-backend/.venv/
# Python cache files
__pycache__/
**/__pycache__/
*.py[cod]
# Download artifacts
download-backend/tmp_downloads/
# Node/Next.js artifacts
download-frontend/node_modules/
download-frontend/.next/
download-frontend/out/
# OS miscellany
.DS_Store
download-backend/tmp_downloads/

Binary file not shown.

View File

@@ -5,6 +5,7 @@ videos via yt-dlp.
from __future__ import annotations from __future__ import annotations
import mimetypes
import shutil import shutil
import tempfile import tempfile
from dataclasses import dataclass from dataclasses import dataclass
@@ -15,6 +16,7 @@ from uuid import uuid4
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, Request from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, Request
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, HttpUrl from pydantic import BaseModel, HttpUrl
from yt_dlp import YoutubeDL from yt_dlp import YoutubeDL
@@ -26,6 +28,9 @@ app = FastAPI(title="Video Download API", version="0.1.0")
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
DOWNLOADS_DIR = BASE_DIR / "tmp_downloads" DOWNLOADS_DIR = BASE_DIR / "tmp_downloads"
DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True) DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True)
WORKING_DIR = BASE_DIR / "tmp_work"
WORKING_DIR.mkdir(parents=True, exist_ok=True)
DOWNLOADS_ROOT = DOWNLOADS_DIR.resolve()
DEFAULT_FORMAT = "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]" DEFAULT_FORMAT = "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]"
ADDITIONAL_FALLBACKS: list[str] = [ ADDITIONAL_FALLBACKS: list[str] = [
@@ -127,7 +132,7 @@ async def download_video(
raise HTTPException(status_code=500, detail="Failed to download video") from exc raise HTTPException(status_code=500, detail="Failed to download video") from exc
background_tasks.add_task(_cleanup_temp_dir, temp_dir) background_tasks.add_task(_cleanup_temp_dir, temp_dir)
download_url = str(request.url_for("downloads", path=file_path.name)) download_url = str(request.url_for("download_file", file_name=file_path.name))
return DownloadResponse( return DownloadResponse(
file_name=file_path.name, file_name=file_path.name,
@@ -188,13 +193,6 @@ def _derive_format_selectors(info: dict[str, Any]) -> list[FormatChoice]:
"""Build a prioritized list of format selectors based on available formats.""" """Build a prioritized list of format selectors based on available formats."""
choices: list[FormatChoice] = [] choices: list[FormatChoice] = []
progressive_mp4 = _pick_best_progressive(info, preferred_ext="mp4")
if progressive_mp4:
choices.append(FormatChoice(str(progressive_mp4["format_id"])))
progressive_any = _pick_best_progressive(info)
if progressive_any:
choices.append(FormatChoice(str(progressive_any["format_id"])))
combo_mp4 = _pick_best_combo(info, video_ext="mp4", audio_ext="m4a") combo_mp4 = _pick_best_combo(info, video_ext="mp4", audio_ext="m4a")
if combo_mp4: if combo_mp4:
@@ -210,6 +208,14 @@ def _derive_format_selectors(info: dict[str, Any]) -> list[FormatChoice]:
selector = f"{video_fmt['format_id']}+{audio_fmt['format_id']}" selector = f"{video_fmt['format_id']}+{audio_fmt['format_id']}"
choices.append(FormatChoice(selector, container)) choices.append(FormatChoice(selector, container))
progressive_mp4 = _pick_best_progressive(info, preferred_ext="mp4")
if progressive_mp4:
choices.append(FormatChoice(str(progressive_mp4["format_id"])))
progressive_any = _pick_best_progressive(info)
if progressive_any:
choices.append(FormatChoice(str(progressive_any["format_id"])))
best_declared = info.get("format_id") best_declared = info.get("format_id")
if best_declared: if best_declared:
choices.append(FormatChoice(str(best_declared))) choices.append(FormatChoice(str(best_declared)))
@@ -323,7 +329,7 @@ def _download_video(
url: str, format_id: str | None, filename: str | None url: str, format_id: str | None, filename: str | None
) -> tuple[Path, Path]: ) -> tuple[Path, Path]:
"""Download a video with yt-dlp, selecting the best available format with graceful fallbacks, and persist it to the downloads directory.""" """Download a video with yt-dlp, selecting the best available format with graceful fallbacks, and persist it to the downloads directory."""
temp_dir = Path(tempfile.mkdtemp(prefix="yt_dlp_")) temp_dir = Path(tempfile.mkdtemp(prefix="yt_dlp_", dir=str(WORKING_DIR)))
output_template = _build_output_template(temp_dir, filename) output_template = _build_output_template(temp_dir, filename)
selectors: list[FormatChoice] selectors: list[FormatChoice]
@@ -396,7 +402,13 @@ def _store_download(file_path: Path) -> Path:
"""Move a completed download into the project downloads directory.""" """Move a completed download into the project downloads directory."""
target = DOWNLOADS_DIR / file_path.name target = DOWNLOADS_DIR / file_path.name
if target.exists(): if target.exists():
target = DOWNLOADS_DIR / f"{file_path.stem}_{uuid4().hex}{file_path.suffix}" try:
if target.is_file():
target.unlink(missing_ok=True)
else:
shutil.rmtree(target, ignore_errors=True)
except OSError:
raise HTTPException(status_code=500, detail="Failed to replace existing file")
shutil.move(str(file_path), target) shutil.move(str(file_path), target)
return target return target
@@ -427,3 +439,34 @@ def _cleanup_temp_dir(temp_dir: Path) -> None:
except OSError: except OSError:
# Ignore cleanup errors; the directory lives in the system temp folder. # Ignore cleanup errors; the directory lives in the system temp folder.
pass pass
@app.get("/api/download/{file_name}", name="download_file")
async def stream_download(file_name: str) -> FileResponse:
"""Stream a stored download with HTTP range support for resumable transfers."""
file_path = _resolve_download_path(file_name)
media_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
stat_result = file_path.stat()
return FileResponse(
path=file_path,
filename=file_path.name,
media_type=media_type,
stat_result=stat_result,
headers={"Accept-Ranges": "bytes"},
)
def _resolve_download_path(file_name: str) -> Path:
"""Ensure the requested file lives inside the downloads directory and exists."""
safe_name = Path(file_name).name
candidate = (DOWNLOADS_DIR / safe_name).resolve()
try:
candidate.relative_to(DOWNLOADS_ROOT)
except ValueError as exc:
raise HTTPException(status_code=404, detail="File not found") from exc
if not candidate.is_file():
raise HTTPException(status_code=404, detail="File not found")
return candidate

View File

@@ -1,12 +1,446 @@
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() { 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 ( return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black"> <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">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-start py-32 px-16 bg-white dark:bg-black sm:items-start"> <div className="mx-auto flex w-full max-w-4xl flex-col gap-8">
<p className="text-center">YouTube Video Downloader</p> <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">
<input className="border-1"/> <div className="flex flex-col gap-2">
</main> <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> </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>
); );
} }