From 09f6a0176a01b37ee82a8dafdefada3347b89cbf Mon Sep 17 00:00:00 2001 From: marcusd Date: Mon, 3 Nov 2025 15:07:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=B8=80=E4=BA=9Bbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- download-backend/app/main.py | 63 ++++++++++++++++++++++++++++------ download-frontend/app/page.tsx | 63 +++++++--------------------------- 2 files changed, 65 insertions(+), 61 deletions(-) diff --git a/download-backend/app/main.py b/download-backend/app/main.py index 226e365..8737d2c 100644 --- a/download-backend/app/main.py +++ b/download-backend/app/main.py @@ -5,6 +5,7 @@ videos via yt-dlp. from __future__ import annotations +import mimetypes import shutil import tempfile from dataclasses import dataclass @@ -15,6 +16,7 @@ from uuid import uuid4 from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, Request from fastapi.concurrency import run_in_threadpool from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, HttpUrl 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 DOWNLOADS_DIR = BASE_DIR / "tmp_downloads" 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]" ADDITIONAL_FALLBACKS: list[str] = [ @@ -127,7 +132,7 @@ async def download_video( raise HTTPException(status_code=500, detail="Failed to download video") from exc 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( 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.""" 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") if combo_mp4: @@ -209,6 +207,14 @@ def _derive_format_selectors(info: dict[str, Any]) -> list[FormatChoice]: container = _guess_merge_container(video_fmt, audio_fmt) selector = f"{video_fmt['format_id']}+{audio_fmt['format_id']}" 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") if best_declared: @@ -323,7 +329,7 @@ def _download_video( url: str, format_id: str | None, filename: str | None ) -> tuple[Path, Path]: """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) selectors: list[FormatChoice] @@ -396,7 +402,13 @@ def _store_download(file_path: Path) -> Path: """Move a completed download into the project downloads directory.""" target = DOWNLOADS_DIR / file_path.name 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) return target @@ -427,3 +439,34 @@ def _cleanup_temp_dir(temp_dir: Path) -> None: except OSError: # Ignore cleanup errors; the directory lives in the system temp folder. 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 diff --git a/download-frontend/app/page.tsx b/download-frontend/app/page.tsx index 7d0f2a4..b1f288b 100644 --- a/download-frontend/app/page.tsx +++ b/download-frontend/app/page.tsx @@ -205,37 +205,6 @@ export default function Home() { } }; - 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 (
@@ -245,7 +214,7 @@ export default function Home() { Video Fetcher

- 高可用视频下载服务 + YTB视频下载服务

输入任意公共视频链接。系统将列出可用清晰度与格式,支持一键最佳质量下载或按需选择单独下载。 @@ -329,18 +298,14 @@ export default function Home() { 自动采用服务推荐的最佳格式,适合快速下载。 {downloads[DEFAULT_DOWNLOAD_KEY]?.status === "success" ? ( - + ) : null} {downloads[DEFAULT_DOWNLOAD_KEY]?.status === "error" ? ( @@ -450,18 +415,14 @@ export default function Home() { : "下载"} {downloadState.status === "success" ? ( - + ) : null} {downloadState.status === "error" ? (