Compare commits

...

1 Commits

Author SHA1 Message Date
09f6a0176a 修改一些bug 2025-11-03 15:07:20 +08:00
2 changed files with 65 additions and 61 deletions

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

@@ -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 ( 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"> <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"> <div className="mx-auto flex w-full max-w-4xl flex-col gap-8">
@@ -245,7 +214,7 @@ export default function Home() {
Video Fetcher Video Fetcher
</span> </span>
<h1 className="text-3xl font-semibold tracking-tight md:text-4xl"> <h1 className="text-3xl font-semibold tracking-tight md:text-4xl">
YTB视频下载服
</h1> </h1>
<p className="max-w-2xl text-base text-zinc-600 dark:text-zinc-400"> <p className="max-w-2xl text-base text-zinc-600 dark:text-zinc-400">
@@ -329,18 +298,14 @@ export default function Home() {
</span> </span>
<span></span> <span></span>
{downloads[DEFAULT_DOWNLOAD_KEY]?.status === "success" ? ( {downloads[DEFAULT_DOWNLOAD_KEY]?.status === "success" ? (
<button <a
type="button" href={downloads[DEFAULT_DOWNLOAD_KEY].url}
onClick={() => { target="_blank"
void triggerBrowserDownload( rel="noopener"
downloads[DEFAULT_DOWNLOAD_KEY].url, 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
);
}}
className="w-fit 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} {downloads[DEFAULT_DOWNLOAD_KEY].file}
</button> </a>
) : null} ) : null}
{downloads[DEFAULT_DOWNLOAD_KEY]?.status === "error" ? ( {downloads[DEFAULT_DOWNLOAD_KEY]?.status === "error" ? (
<span className="text-sm text-red-500"> <span className="text-sm text-red-500">
@@ -450,18 +415,14 @@ export default function Home() {
: "下载"} : "下载"}
</button> </button>
{downloadState.status === "success" ? ( {downloadState.status === "success" ? (
<button <a
type="button" href={downloadState.url}
onClick={() => { target="_blank"
void triggerBrowserDownload( rel="noopener"
downloadState.url,
downloadState.file
);
}}
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" 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} {downloadState.file}
</button> </a>
) : null} ) : null}
{downloadState.status === "error" ? ( {downloadState.status === "error" ? (
<span className="text-xs text-red-500"> <span className="text-xs text-red-500">