#!/usr/bin/env python3 """vipsong Web — 音乐加密格式转码工具 (Web 版)。 并发安全说明: - vipsong.convert_file() 会修改模块级全局 stats 字典,因此所有转码调用 被互斥锁 (_convert_lock) 串行化,避免多线程/协程并发时统计数据错乱 或输出路径冲突。 - 每隔 10 分钟自动清扫 static/uploads 与 static/outputs 目录,释放磁盘。 """ import shutil import threading from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, File, UploadFile, Form, HTTPException from fastapi.responses import HTMLResponse, FileResponse from fastapi.staticfiles import StaticFiles import vipsong # --- 并发隔离 --------------------------------------------- # vipsong.convert_file() 内部使用模块全局 stats, 需加锁串行化 _convert_lock = threading.Lock() # --- 定期清理 ----------------------------------------------- _CLEANUP_INTERVAL = 10 * 60 # 10 分钟 _cleanup_stop = threading.Event() def _purge_directory(directory: Path): """删除目录内所有文件(保留目录本身)。""" if not directory.exists(): return for entry in directory.iterdir(): try: if entry.is_file() or entry.is_symlink(): entry.unlink(missing_ok=True) elif entry.is_dir(): shutil.rmtree(entry, ignore_errors=True) except OSError: pass def _cleanup_loop(): """后台线程:每隔 _CLEANUP_INTERVAL 清扫上传和输出目录。""" while not _cleanup_stop.wait(_CLEANUP_INTERVAL): _purge_directory(UPLOAD_DIR) _purge_directory(OUTPUT_DIR) @asynccontextmanager async def lifespan(app: FastAPI): # 启动清理线程 t = threading.Thread(target=_cleanup_loop, daemon=True, name="vipsong-cleanup") t.start() yield # 关闭时停止清理线程 _cleanup_stop.set() t.join(timeout=5) app = FastAPI(title="vipsong Web", description="音乐加密格式转码工具", lifespan=lifespan) BASE_DIR = Path(__file__).parent UPLOAD_DIR = BASE_DIR / "static" / "uploads" OUTPUT_DIR = BASE_DIR / "static" / "outputs" UPLOAD_DIR.mkdir(parents=True, exist_ok=True) OUTPUT_DIR.mkdir(parents=True, exist_ok=True) app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static") SUPPORTED_EXTENSIONS = [".ncm", ".kgm", ".kgma", ".vpr"] SUPPORTED_FORMATS = ["mp3", "flac", "wav", "ogg", "m4a"] def is_supported(filename: str) -> bool: name = filename.lower() return any(name.endswith(ext) for ext in SUPPORTED_EXTENSIONS) @app.get("/", response_class=HTMLResponse) async def index(): html = (BASE_DIR / "templates" / "index.html").read_text(encoding="utf-8") return HTMLResponse(html) @app.post("/convert") async def convert( file: UploadFile = File(...), format: str = Form("mp3"), ): """接收单文件上传,转码后返回下载链接。""" if not is_supported(file.filename): raise HTTPException(status_code=400, detail=f"不支持的文件格式: {file.filename}") if format not in SUPPORTED_FORMATS: raise HTTPException(status_code=400, detail=f"不支持的输出格式: {format}") # 清理文件名中的危险字符(保留扩展名中的点) safe_name = "".join(c for c in file.filename if c not in "/\\<>:\"|?*" and ord(c) > 31) upload_path = UPLOAD_DIR / safe_name with open(upload_path, "wb") as f: shutil.copyfileobj(file.file, f) output_name = Path(safe_name).stem + "." + format output_path = OUTPUT_DIR / output_name try: # 加锁串行化: vipsong.convert_file() 修改全局 stats, 多线程下需隔离 with _convert_lock: vipsong.convert_file( str(upload_path), format, delete_flag=False, output_path=str(output_path), ) except Exception as e: raise HTTPException(status_code=500, detail=f"转码失败: {e}") finally: # 清理上传的临时文件 if upload_path.exists(): upload_path.unlink(missing_ok=True) return { "original_name": file.filename, "output_name": output_name, "download_url": f"/download/{output_name}", } @app.get("/download/{filename}") async def download(filename: str): file_path = OUTPUT_DIR / filename if not file_path.exists(): raise HTTPException(status_code=404, detail="文件不存在") return FileResponse(path=str(file_path), filename=filename) @app.get("/health") async def health(): return {"status": "ok"}