Web 前端: HTML上传+转码界面 + FastAPI后端

- app.py: FastAPI 后端,POST /convert 接收文件上传并调用 vipsong.convert_file 转码
  GET /download/<filename> 提供下载,GET /health 健康检查
  文件名安全过滤保留扩展名,支持多格式输出(mp3/flac/wav/ogg/m4a)
- templates/index.html: 纯HTML/CSS/JS单页应用,拖拽上传、格式选择、状态显示、下载链接
- .gitignore: 添加 static/uploads/、static/outputs/、音频输出文件过滤
- requirements.txt: 添加 fastapi、uvicorn、python-multipart

验证 MCP 可用性:
- Baota MCP SystemInfo/SiteList/ServiceStatus 全部正常返回
- nginx+mysql+php-fpm-74/82/85 运行中,5个网站正常
- Web API 端对端测试: NCM→MP3/FLAC/OGG 均输出有效音频,错误处理返回 HTTP 400
This commit is contained in:
ypshidifu
2026-09-17 17:10:55 +08:00
parent 8a281f584c
commit 8dd6aebd8b
3 changed files with 303 additions and 0 deletions
BIN
View File
Binary file not shown.
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""vipsong Web — 音乐加密格式转码工具 (Web 版)。"""
import os
import shutil
from pathlib import Path
from fastapi import FastAPI, File, UploadFile, Form, HTTPException, BackgroundTasks
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
import vipsong
app = FastAPI(title="vipsong Web", description="音乐加密格式转码工具")
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(
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"}
+215
View File
@@ -0,0 +1,215 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>vipsong Web — 音乐加密格式转码</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f6fa; color: #2d3436; min-height: 100vh; padding: 2rem;
}
.container { max-width: 720px; margin: 0 auto; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
.subtitle { color: #636e72; font-size: 0.9rem; margin-bottom: 1.5rem; }
.upload-area {
border: 2px dashed #dfe6e9; border-radius: 12px; padding: 2.5rem 1.5rem;
text-align: center; cursor: pointer; transition: all .2s; background: #fff;
}
.upload-area:hover { border-color: #0984e3; background: #f8f9fa; }
.upload-area.dragover { border-color: #0984e3; background: #e3f2fd; }
.upload-area i { font-size: 2rem; color: #b2bec3; margin-bottom: 0.5rem; display: block; }
.upload-area p { color: #636e72; font-size: 0.9rem; }
.format-selector {
margin: 1.5rem 0; display: flex; align-items: center; gap: 1rem;
}
.format-selector label { font-weight: 600; color: #2d3436; }
select {
padding: 0.5rem 1rem; border: 1px solid #dfe6e9; border-radius: 8px;
font-size: 0.9rem; background: #fff; cursor: pointer;
}
.btn-convert {
display: block; width: 100%; padding: 0.8rem; margin-top: 1rem;
background: #0984e3; color: #fff; border: none; border-radius: 8px;
font-size: 1rem; font-weight: 600; cursor: pointer; transition: .2s;
}
.btn-convert:hover:not(:disabled) { background: #074c8a; }
.btn-convert:disabled { background: #b2bec3; cursor: not-allowed; }
.file-list { margin-top: 1.5rem; }
.file-item {
display: flex; align-items: center; justify-content: space-between;
padding: 0.8rem 1rem; border-radius: 8px; margin-bottom: 0.5rem;
background: #fff; border: 1px solid #dfe6e9;
}
.file-info { display: flex; align-items: center; gap: 0.8rem; }
.file-name { font-size: 0.9rem; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-status { font-size: 0.8rem; padding: 0.2rem 0.6rem; border-radius: 12px; }
.status-pending { background: #f1f2f6; color: #636e72; }
.status-upload { background: #fff3cd; color: #856404; }
.status-convert { background: #fff3cd; color: #856404; }
.status-done { background: #d4edda; color: #155724; }
.status-error { background: #f8d7da; color: #721c24; }
.btn-download { padding: 0.3rem 0.8rem; background: #00b894; color: #fff; border: none; border-radius: 6px; font-size: 0.8rem; cursor: pointer; }
.btn-download:hover { background: #0984e3; }
.supported { margin-top: 1rem; font-size: 0.8rem; color: #636e72; }
.supported span { color: #0984e3; font-weight: 600; }
@media (max-width: 480px) {
body { padding: 1rem; }
h1 { font-size: 1.2rem; }
}
</style>
</head>
<body>
<div class="container">
<h1>vipsong — 音乐加密格式转码</h1>
<p class="subtitle">支持 .ncm (网易云) / .kgm .vpr (酷狗) → mp3 / flac / wav / ogg / m4a</p>
<form id="uploadForm">
<div class="upload-area" id="dropZone">
<i>📁</i>
<p>点击选择文件,或拖拽到这里</p>
<input type="file" id="fileInput" multiple accept=".ncm,.kgm,.kgma,.vpr" style="display:none">
</div>
<div class="format-selector">
<label>输出格式:</label>
<select id="formatSelect">
<option value="mp3">MP3 (默认)</option>
<option value="flac">FLAC (无损)</option>
<option value="wav">WAV (无损)</option>
<option value="ogg">OGG</option>
<option value="m4a">M4A</option>
</select>
</div>
<button type="button" id="btnConvert" class="btn-convert" disabled>开始转码</button>
</form>
<div class="file-list" id="fileList"></div>
<p class="supported">
<span>支持格式:</span> .ncm / .kgm / .kgma / .vpr
&nbsp;|&nbsp; <span>输出格式:</span> mp3 / flac / wav / ogg / m4a
</p>
</div>
<script>
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const formatSelect = document.getElementById('formatSelect');
const btnConvert = document.getElementById('btnConvert');
const fileList = document.getElementById('fileList');
let files = [];
// 点击上传区域选择文件
dropZone.addEventListener('click', () => fileInput.click());
// 拖拽
['dragenter', 'dragover'].forEach(evt => {
dropZone.addEventListener(evt, e => {
e.preventDefault(); e.stopPropagation();
dropZone.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(evt => {
dropZone.addEventListener(evt, e => {
e.preventDefault(); e.stopPropagation();
dropZone.classList.remove('dragover');
});
});
// 拖拽放下
dropZone.addEventListener('drop', e => {
const dt = e.dataTransfer;
handleFiles(dt.files);
});
// 文件选择
fileInput.addEventListener('change', e => {
handleFiles(e.target.files);
});
function handleFiles(fileListObj) {
for (const f of fileListObj) {
if (files.some(existing => existing.name === f.name)) continue;
files.push(f);
}
renderFileList();
}
function renderFileList() {
fileList.innerHTML = '';
if (files.length === 0) {
btnConvert.disabled = true;
return;
}
btnConvert.disabled = false;
files.forEach((f, idx) => {
const item = document.createElement('div');
item.className = 'file-item';
item.innerHTML = `
<div class="file-info">
<span class="file-name">${f.name}</span>
<span class="file-status status-pending" id="status-${idx}">等待</span>
</div>
<div id="actions-${idx}"></div>
`;
fileList.appendChild(item);
});
}
function setStatus(idx, text, cls) {
const el = document.getElementById(`status-${idx}`);
if (el) { el.textContent = text; el.className = 'file-status status-' + cls; }
}
async function convertFile(file, idx) {
const formData = new FormData();
formData.append('file', file);
formData.append('format', formatSelect.value);
setStatus(idx, '上传中', 'upload');
try {
const resp = await fetch('/convert', { method: 'POST', body: formData });
const data = await resp.json();
if (!resp.ok) throw new Error(data.detail || '转换失败');
setStatus(idx, '完成', 'done');
const actions = document.getElementById(`actions-${idx}`);
const a = document.createElement('a');
a.href = data.download_url;
a.textContent = '下载';
a.className = 'btn-download';
a.download = data.output_name;
actions.appendChild(a);
} catch (error) {
setStatus(idx, '失败: ' + error.message, 'error');
}
}
// 开始转码
btnConvert.addEventListener('click', async () => {
btnConvert.disabled = true;
btnConvert.textContent = '转码中...';
for (let i = 0; i < files.length; i++) {
await convertFile(files[i], i);
}
btnConvert.disabled = false;
btnConvert.textContent = '开始转码';
});
</script>
</body>
</html>