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:
BIN
Binary file not shown.
@@ -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"}
|
||||||
@@ -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
|
||||||
|
| <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>
|
||||||
Reference in New Issue
Block a user