nfo / app.py
ljx77qaq's picture
Update app.py
8e4a00e verified
Raw
History Blame Contribute Delete
27 kB
import os
import base64
import tempfile
import uuid
import re
from typing import Optional
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.responses import HTMLResponse, FileResponse
from mutagen.mp3 import MP3
from mutagen.id3 import (
TIT2, TPE1, TALB, TDRC, TRCK, TCON, COMM, APIC,
TPE2, TCOM, TPOS, USLT, SYLT
)
app = FastAPI()
TEMP_DIR = tempfile.mkdtemp()
file_store = {}
def safe_filename(name):
name = re.sub(r'[<>:"/\\|?*]', '_', name)
return name.strip('. ')
def extract_cover(tags):
if tags is None:
return None
for key in tags:
if key.startswith("APIC"):
apic = tags[key]
data = base64.b64encode(apic.data).decode("utf-8")
return {"mime": apic.mime, "data": f"data:{apic.mime};base64,{data}"}
return None
def extract_lyrics(tags):
if tags is None:
return ""
for key in tags:
if key.startswith("USLT"):
return str(tags[key])
return ""
def get_tag_text(tags, key, default=""):
if tags is None:
return default
tag = tags.get(key)
return str(tag) if tag else default
@app.post("/api/upload")
async def upload_file(file: UploadFile = File(...)):
if not file.filename.lower().endswith(".mp3"):
raise HTTPException(status_code=400, detail="仅支持 MP3 文件")
file_id = str(uuid.uuid4())
file_path = os.path.join(TEMP_DIR, f"{file_id}.mp3")
with open(file_path, "wb") as f:
content = await file.read()
f.write(content)
file_store[file_id] = {"path": file_path, "original_name": file.filename}
try:
audio = MP3(file_path)
tags = audio.tags
comment = ""
if tags:
for key in tags:
if key.startswith("COMM"):
comment = str(tags[key])
break
return {
"file_id": file_id,
"filename": file.filename,
"filename_noext": os.path.splitext(file.filename)[0],
"duration": round(audio.info.length, 2),
"bitrate": audio.info.bitrate // 1000,
"sample_rate": audio.info.sample_rate,
"channels": audio.info.channels,
"tags": {
"title": get_tag_text(tags, "TIT2"),
"artist": get_tag_text(tags, "TPE1"),
"album": get_tag_text(tags, "TALB"),
"album_artist": get_tag_text(tags, "TPE2"),
"year": get_tag_text(tags, "TDRC"),
"track": get_tag_text(tags, "TRCK"),
"disc": get_tag_text(tags, "TPOS"),
"genre": get_tag_text(tags, "TCON"),
"composer": get_tag_text(tags, "TCOM"),
"comment": comment,
"lyrics": extract_lyrics(tags),
},
"cover": extract_cover(tags),
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"读取失败: {str(e)}")
@app.post("/api/save")
async def save_tags(
file_id: str = Form(...),
new_filename: str = Form(""),
title: str = Form(""),
artist: str = Form(""),
album: str = Form(""),
album_artist: str = Form(""),
year: str = Form(""),
track: str = Form(""),
disc: str = Form(""),
genre: str = Form(""),
composer: str = Form(""),
comment: str = Form(""),
lyrics: str = Form(""),
cover: Optional[UploadFile] = File(None),
remove_cover: str = Form("false"),
):
if file_id not in file_store:
raise HTTPException(status_code=404, detail="文件不存在,请重新上传")
file_path = file_store[file_id]["path"]
try:
audio = MP3(file_path)
try:
audio.add_tags()
except Exception:
pass
tags = audio.tags
tag_map = {
"TIT2": (TIT2, title),
"TPE1": (TPE1, artist),
"TALB": (TALB, album),
"TPE2": (TPE2, album_artist),
"TDRC": (TDRC, year),
"TRCK": (TRCK, track),
"TPOS": (TPOS, disc),
"TCON": (TCON, genre),
"TCOM": (TCOM, composer),
}
for key, (cls, val) in tag_map.items():
if val:
tags[key] = cls(encoding=3, text=val)
elif key in tags:
del tags[key]
# Comment
for k in [k for k in tags if k.startswith("COMM")]:
del tags[k]
if comment:
tags["COMM::eng"] = COMM(encoding=3, lang="eng", desc="", text=comment)
# Lyrics
for k in [k for k in tags if k.startswith("USLT")]:
del tags[k]
if lyrics:
tags["USLT::eng"] = USLT(encoding=3, lang="eng", desc="", text=lyrics)
# Cover
if remove_cover == "true":
for k in [k for k in tags if k.startswith("APIC")]:
del tags[k]
elif cover:
cover_data = await cover.read()
mime = cover.content_type or "image/jpeg"
for k in [k for k in tags if k.startswith("APIC")]:
del tags[k]
tags["APIC:"] = APIC(encoding=3, mime=mime, type=3, desc="Cover", data=cover_data)
audio.save()
# Update filename
if new_filename:
clean = safe_filename(new_filename)
if clean:
file_store[file_id]["original_name"] = clean + ".mp3"
return {"success": True, "message": "保存成功", "filename": file_store[file_id]["original_name"]}
except Exception as e:
raise HTTPException(status_code=500, detail=f"保存失败: {str(e)}")
@app.get("/api/download/{file_id}")
async def download_file(file_id: str):
if file_id not in file_store:
raise HTTPException(status_code=404, detail="文件不存在")
return FileResponse(
file_store[file_id]["path"],
media_type="audio/mpeg",
filename=file_store[file_id]["original_name"],
)
@app.delete("/api/file/{file_id}")
async def delete_file(file_id: str):
if file_id in file_store:
try:
os.remove(file_store[file_id]["path"])
except Exception:
pass
del file_store[file_id]
return {"success": True}
HTML = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MP3 Tag Editor</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f0f13;color:#e0e0e0;min-height:100vh}
.header{background:#1a1a24;border-bottom:1px solid #2a2a3a;padding:14px 24px;display:flex;align-items:center;gap:12px}
.header h1{font-size:20px;font-weight:700;color:#63e2b7}
.header span{color:#888;font-size:13px}
.container{max-width:960px;margin:20px auto;padding:0 16px}
.card{background:#1a1a24;border:1px solid #2a2a3a;border-radius:10px;padding:20px;margin-bottom:14px}
.card-title{font-size:14px;font-weight:600;margin-bottom:14px;color:#999;text-transform:uppercase;letter-spacing:.5px}
.upload-zone{border:2px dashed #3a3a4a;border-radius:10px;padding:48px 24px;text-align:center;cursor:pointer;transition:all .2s}
.upload-zone:hover,.upload-zone.dragover{border-color:#63e2b7;background:rgba(99,226,183,.05)}
.upload-zone svg{width:48px;height:48px;color:#555;margin-bottom:12px}
.upload-zone .main-text{font-size:16px;color:#ccc}
.upload-zone p{color:#888;margin-top:6px}
.file-bar{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px}
.file-bar-left{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.file-name{font-weight:600;color:#63e2b7}
.badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;background:#2a2a3a;color:#aaa}
.badge.b{background:#1a3a5c;color:#7ec8e3}
.badge.g{background:#1a3c2a;color:#63e2b7}
.btn-text{background:none;border:none;color:#e06c75;cursor:pointer;font-size:13px;padding:4px 8px;border-radius:4px}
.btn-text:hover{background:rgba(224,108,117,.1)}
.editor-grid{display:grid;grid-template-columns:220px 1fr;gap:14px}
@media(max-width:700px){.editor-grid{grid-template-columns:1fr}}
.cover-box{width:100%;aspect-ratio:1;border-radius:8px;overflow:hidden;background:#12121a;border:1px dashed #2a2a3a;display:flex;align-items:center;justify-content:center;cursor:pointer;position:relative}
.cover-box img{width:100%;height:100%;object-fit:cover}
.cover-ph{text-align:center;color:#444}
.cover-ph svg{width:40px;height:40px}
.cover-actions{display:flex;flex-direction:column;gap:6px;margin-top:10px}
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.form-grid .full{grid-column:1/-1}
.fg{display:flex;flex-direction:column;gap:3px}
.fg label{font-size:11px;color:#777;font-weight:500;text-transform:uppercase;letter-spacing:.3px}
.fg input,.fg textarea,.fg select{background:#12121a;border:1px solid #2a2a3a;border-radius:6px;padding:8px 10px;color:#e0e0e0;font-size:13px;outline:none;transition:border-color .2s;width:100%;font-family:inherit}
.fg input:focus,.fg textarea:focus{border-color:#63e2b7}
.fg textarea{resize:vertical;min-height:60px;font-size:13px}
.fg .lyrics-area{min-height:140px;font-family:'Courier New',monospace;font-size:12px;line-height:1.6}
.btn{padding:7px 18px;border-radius:6px;border:1px solid transparent;cursor:pointer;font-size:13px;font-weight:500;transition:all .2s;display:inline-flex;align-items:center;gap:5px}
.btn:disabled{opacity:.35;cursor:not-allowed}
.btn-p{background:#63e2b7;color:#0f0f13;border-color:#63e2b7}
.btn-p:hover:not(:disabled){background:#7aebb0}
.btn-s{background:#2a6b4a;color:#e0e0e0;border-color:#3a8a5a}
.btn-s:hover:not(:disabled){background:#3a8a5a}
.btn-g{background:transparent;color:#aaa;border-color:#3a3a4a}
.btn-g:hover:not(:disabled){border-color:#63e2b7;color:#63e2b7}
.btn-d{background:transparent;color:#e06c75;border-color:#5a2a2a}
.btn-d:hover:not(:disabled){background:rgba(224,108,117,.1)}
.btn-block{width:100%;justify-content:center}
.actions{display:flex;justify-content:flex-end;gap:8px;margin-top:14px;flex-wrap:wrap}
.loading{text-align:center;padding:48px;color:#888}
.spinner{width:32px;height:32px;border:3px solid #2a2a3a;border-top-color:#63e2b7;border-radius:50%;animation:spin .8s linear infinite;margin:0 auto 12px}
@keyframes spin{to{transform:rotate(360deg)}}
.footer{text-align:center;padding:16px;color:#444;font-size:11px;border-top:1px solid #1a1a24;margin-top:20px}
.toast{position:fixed;top:16px;right:16px;padding:10px 18px;border-radius:8px;font-size:13px;z-index:9999;animation:slideIn .3s ease;max-width:340px}
.toast.ok{background:#1a3c2a;color:#63e2b7;border:1px solid #2a5a3a}
.toast.err{background:#3c1a1a;color:#e06c75;border:1px solid #5a2a2a}
.toast.info{background:#1a2a3c;color:#7ec8e3;border:1px solid #2a3a5a}
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.75);display:flex;align-items:center;justify-content:center;z-index:9998}
.modal-box{background:#1a1a24;border:1px solid #2a2a3a;border-radius:12px;padding:20px;max-width:500px;width:90%}
.modal-box img{width:100%;border-radius:8px}
.modal-hd{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}
.modal-hd h3{font-size:15px;color:#ccc}
input[type="file"]{display:none}
.tabs{display:flex;gap:0;margin-bottom:14px;border-bottom:1px solid #2a2a3a}
.tab{padding:8px 18px;cursor:pointer;font-size:13px;color:#888;border-bottom:2px solid transparent;transition:all .2s;user-select:none}
.tab:hover{color:#ccc}
.tab.active{color:#63e2b7;border-bottom-color:#63e2b7}
.tab-panel{display:none}
.tab-panel.active{display:block}
.filename-row{display:flex;gap:8px;align-items:flex-end}
.filename-row .fg{flex:1}
.filename-ext{color:#666;font-size:14px;padding:8px 0;white-space:nowrap}
</style>
</head>
<body>
<div class="header">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#63e2b7" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
<h1>MP3 Tag Editor</h1>
<span>在线编辑 MP3 标签 / 歌词 / 封面 / 文件名</span>
</div>
<div class="container">
<!-- Upload -->
<div class="card" id="uploadCard">
<div class="upload-zone" id="uploadZone" onclick="$('fileInput').click()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 16V4m0 0L8 8m4-4l4 4"/><path d="M20 16v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2"/></svg>
<p class="main-text">点击或拖拽 MP3 文件到此处</p>
<p>支持 .mp3 格式</p>
</div>
<input type="file" id="fileInput" accept=".mp3">
</div>
<!-- Loading -->
<div class="card" id="loadingCard" style="display:none">
<div class="loading"><div class="spinner"></div><p>正在解析文件...</p></div>
</div>
<!-- Editor -->
<div id="editor" style="display:none">
<div class="card">
<div class="file-bar">
<div class="file-bar-left">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#63e2b7" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
<span class="file-name" id="dispName"></span>
<span class="badge b" id="dispBit"></span>
<span class="badge g" id="dispSR"></span>
<span class="badge" id="dispDur"></span>
<span class="badge" id="dispCh"></span>
</div>
<button class="btn-text" onclick="resetFile()">✕ 关闭</button>
</div>
</div>
<div class="editor-grid">
<!-- Left -->
<div>
<div class="card">
<div class="card-title">封面</div>
<div class="cover-box" id="coverBox" onclick="previewCover()">
<img id="coverImg" style="display:none">
<div class="cover-ph" id="coverPH">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
<p style="margin-top:6px;font-size:12px">无封面</p>
</div>
</div>
<div class="cover-actions">
<button class="btn btn-g btn-block" onclick="$('coverInput').click()"><span id="coverBtnT">添加封面</span></button>
<button class="btn btn-d btn-block" id="rmCoverBtn" style="display:none" onclick="removeCover()">移除封面</button>
</div>
<input type="file" id="coverInput" accept="image/*">
</div>
</div>
<!-- Right -->
<div>
<div class="card">
<!-- Tabs -->
<div class="tabs">
<div class="tab active" onclick="switchTab('basic',this)">基本信息</div>
<div class="tab" onclick="switchTab('lyrics',this)">歌词</div>
<div class="tab" onclick="switchTab('file',this)">文件名</div>
</div>
<!-- Tab: Basic -->
<div class="tab-panel active" id="panel_basic">
<div class="form-grid">
<div class="fg full"><label>标题 Title</label><input id="t_title" placeholder="歌曲标题"></div>
<div class="fg"><label>艺术家 Artist</label><input id="t_artist" placeholder="表演者"></div>
<div class="fg"><label>专辑艺术家</label><input id="t_album_artist" placeholder="Album Artist"></div>
<div class="fg"><label>专辑 Album</label><input id="t_album" placeholder="专辑名称"></div>
<div class="fg"><label>年份 Year</label><input id="t_year" placeholder="发行年份"></div>
<div class="fg"><label>音轨 Track</label><input id="t_track" placeholder="1 或 1/12"></div>
<div class="fg"><label>碟片 Disc</label><input id="t_disc" placeholder="1 或 1/2"></div>
<div class="fg"><label>流派 Genre</label><input id="t_genre" placeholder="流派" list="gl">
<datalist id="gl">
<option value="Pop"><option value="Rock"><option value="Hip-Hop"><option value="R&B">
<option value="Jazz"><option value="Blues"><option value="Classical"><option value="Country">
<option value="Electronic"><option value="Dance"><option value="Folk"><option value="Indie">
<option value="Metal"><option value="Punk"><option value="Soul"><option value="Alternative">
<option value="Ambient"><option value="Funk"><option value="Latin"><option value="Soundtrack">
<option value="J-Pop"><option value="K-Pop"><option value="C-Pop"><option value="Anime">
<option value="Game"><option value="Instrumental"><option value="New Age"><option value="Reggae">
</datalist>
</div>
<div class="fg"><label>作曲 Composer</label><input id="t_composer" placeholder="作曲者"></div>
<div class="fg full"><label>注释 Comment</label><textarea id="t_comment" placeholder="注释信息"></textarea></div>
</div>
</div>
<!-- Tab: Lyrics -->
<div class="tab-panel" id="panel_lyrics">
<div class="fg">
<label>歌词 Lyrics(支持 LRC 时间轴格式)</label>
<textarea id="t_lyrics" class="lyrics-area" placeholder="[00:00.00] 在此粘贴歌词...&#10;[00:05.20] 支持 LRC 格式&#10;&#10;也可以输入纯文本歌词"></textarea>
</div>
<div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-g" onclick="clearLyrics()">清空歌词</button>
<label class="btn btn-g" style="cursor:pointer">
导入 .lrc 文件
<input type="file" accept=".lrc,.txt" onchange="importLrc(event)" style="display:none">
</label>
<button class="btn btn-g" onclick="exportLrc()">导出 .lrc</button>
</div>
</div>
<!-- Tab: Filename -->
<div class="tab-panel" id="panel_file">
<div class="fg" style="margin-bottom:12px">
<label>原始文件名</label>
<input id="origName" disabled style="opacity:.5">
</div>
<div class="filename-row">
<div class="fg">
<label>新文件名(不含扩展名)</label>
<input id="t_filename" placeholder="输入新文件名">
</div>
<div class="filename-ext">.mp3</div>
</div>
<div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-g" onclick="autoFilename('ta')" title="艺术家 - 标题">套用: 艺术家 - 标题</button>
<button class="btn btn-g" onclick="autoFilename('tta')" title="音轨. 标题 - 艺术家">套用: 音轨. 标题</button>
<button class="btn btn-g" onclick="autoFilename('t')" title="标题">套用: 标题</button>
</div>
</div>
</div>
<div class="actions">
<button class="btn btn-g" id="resetBtn" onclick="resetTags()">🔄 重置</button>
<button class="btn btn-p" id="saveBtn" onclick="saveTags()">💾 保存</button>
<button class="btn btn-s" id="dlBtn" onclick="downloadFile()" disabled>📥 下载</button>
</div>
</div>
</div>
</div>
</div>
<div class="footer">MP3 Tag Editor · Mutagen + FastAPI · 文件仅临时存储于服务端内存</div>
<!-- Cover Modal -->
<div class="modal-overlay" id="coverModal" style="display:none" onclick="if(event.target===this)this.style.display='none'">
<div class="modal-box" onclick="event.stopPropagation()">
<div class="modal-hd"><h3>封面预览</h3><button class="btn-text" onclick="$('coverModal').style.display='none'">✕</button></div>
<img id="coverModalImg">
</div>
</div>
<script>
const $=id=>document.getElementById(id);
const TAGS=['title','artist','album','album_artist','year','track','disc','genre','composer','comment'];
let info=null, origTags={}, coverFile=null, rmCover=false;
function toast(m,t='info'){const d=document.createElement('div');d.className='toast '+(t==='ok'?'ok':t==='err'?'err':'info');d.textContent=m;document.body.appendChild(d);setTimeout(()=>{d.style.opacity='0';d.style.transition='opacity .3s';setTimeout(()=>d.remove(),300)},3000)}
// Tab switching
function switchTab(name,el){
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
el.classList.add('active');
$('panel_'+name).classList.add('active');
}
// Upload
const uz=$('uploadZone');
uz.addEventListener('dragover',e=>{e.preventDefault();uz.classList.add('dragover')});
uz.addEventListener('dragleave',()=>uz.classList.remove('dragover'));
uz.addEventListener('drop',e=>{e.preventDefault();uz.classList.remove('dragover');const f=e.dataTransfer.files;if(f.length&&f[0].name.toLowerCase().endsWith('.mp3'))upload(f[0]);else toast('请上传 .mp3 文件','err')});
$('fileInput').addEventListener('change',e=>{if(e.target.files.length)upload(e.target.files[0])});
$('coverInput').addEventListener('change',e=>{
if(!e.target.files.length)return;
coverFile=e.target.files[0];rmCover=false;
const r=new FileReader();
r.onload=ev=>{$('coverImg').src=ev.target.result;$('coverImg').style.display='block';$('coverPH').style.display='none';$('rmCoverBtn').style.display='block';$('coverBtnT').textContent='更换封面'};
r.readAsDataURL(coverFile);
});
async function upload(file){
$('uploadCard').style.display='none';$('loadingCard').style.display='block';
const fd=new FormData();fd.append('file',file);
try{
const r=await fetch('/api/upload',{method:'POST',body:fd});
if(!r.ok){const e=await r.json();throw new Error(e.detail||'上传失败')}
info=await r.json();
TAGS.forEach(f=>{const el=$('t_'+f);if(el)el.value=info.tags[f]||''});
$('t_lyrics').value=info.tags.lyrics||'';
origTags={...info.tags};
$('dispName').textContent=info.filename;
$('dispBit').textContent=info.bitrate+' kbps';
$('dispSR').textContent=info.sample_rate+' Hz';
$('dispCh').textContent=info.channels+'ch';
const mm=Math.floor(info.duration/60),ss=Math.floor(info.duration%60);
$('dispDur').textContent=mm+':'+String(ss).padStart(2,'0');
$('origName').value=info.filename;
$('t_filename').value=info.filename_noext;
if(info.cover&&info.cover.data){$('coverImg').src=info.cover.data;$('coverImg').style.display='block';$('coverPH').style.display='none';$('rmCoverBtn').style.display='block';$('coverBtnT').textContent='更换封面'}
else{$('coverImg').style.display='none';$('coverPH').style.display='flex';$('rmCoverBtn').style.display='none';$('coverBtnT').textContent='添加封面'}
coverFile=null;rmCover=false;$('dlBtn').disabled=true;
$('loadingCard').style.display='none';$('editor').style.display='block';
toast('文件解析成功','ok');
}catch(e){$('loadingCard').style.display='none';$('uploadCard').style.display='block';toast(e.message,'err')}
}
function removeCover(){$('coverImg').style.display='none';$('coverPH').style.display='flex';$('rmCoverBtn').style.display='none';$('coverBtnT').textContent='添加封面';coverFile=null;rmCover=true}
function resetTags(){
TAGS.forEach(f=>{const el=$('t_'+f);if(el)el.value=origTags[f]||''});
$('t_lyrics').value=origTags.lyrics||'';
$('t_filename').value=info.filename_noext;
if(info.cover&&info.cover.data){$('coverImg').src=info.cover.data;$('coverImg').style.display='block';$('coverPH').style.display='none';$('rmCoverBtn').style.display='block';$('coverBtnT').textContent='更换封面'}
else{$('coverImg').style.display='none';$('coverPH').style.display='flex';$('rmCoverBtn').style.display='none';$('coverBtnT').textContent='添加封面'}
coverFile=null;rmCover=false;toast('已重置','info');
}
async function saveTags(){
if(!info)return;
$('saveBtn').disabled=true;$('saveBtn').textContent='⏳ 保存中...';
const fd=new FormData();
fd.append('file_id',info.file_id);
fd.append('new_filename',$('t_filename').value);
TAGS.forEach(f=>fd.append(f,$('t_'+f)?$('t_'+f).value:''));
fd.append('lyrics',$('t_lyrics').value);
fd.append('remove_cover',rmCover?'true':'false');
if(coverFile)fd.append('cover',coverFile);
try{
const r=await fetch('/api/save',{method:'POST',body:fd});
if(!r.ok){const e=await r.json();throw new Error(e.detail||'保存失败')}
const res=await r.json();
$('dlBtn').disabled=false;
if(res.filename){$('dispName').textContent=res.filename;info.original_name=res.filename}
toast('保存成功!可以下载了','ok');
}catch(e){toast(e.message,'err')}
finally{$('saveBtn').disabled=false;$('saveBtn').textContent='💾 保存'}
}
function downloadFile(){if(!info)return;const a=document.createElement('a');a.href='/api/download/'+info.file_id;a.download=info.original_name||info.filename;a.click()}
async function resetFile(){
if(info){try{await fetch('/api/file/'+info.file_id,{method:'DELETE'})}catch{}}
info=null;coverFile=null;rmCover=false;
$('editor').style.display='none';$('uploadCard').style.display='block';
$('fileInput').value='';$('coverInput').value='';
TAGS.forEach(f=>{const el=$('t_'+f);if(el)el.value=''});
$('t_lyrics').value='';$('t_filename').value='';
}
function previewCover(){if($('coverImg').style.display!=='none'){$('coverModalImg').src=$('coverImg').src;$('coverModal').style.display='flex'}}
// Lyrics helpers
function clearLyrics(){$('t_lyrics').value='';toast('歌词已清空','info')}
function importLrc(e){
const file=e.target.files[0];if(!file)return;
const r=new FileReader();
r.onload=ev=>{$('t_lyrics').value=ev.target.result;toast('歌词已导入','ok')};
r.readAsText(file,'utf-8');
e.target.value='';
}
function exportLrc(){
const text=$('t_lyrics').value;if(!text.trim()){toast('没有歌词可导出','err');return}
const title=$('t_title').value||'lyrics';
const blob=new Blob([text],{type:'text/plain;charset=utf-8'});
const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=title+'.lrc';a.click();URL.revokeObjectURL(a.href);
}
// Auto filename
function autoFilename(mode){
const t=$('t_title').value,a=$('t_artist').value,tr=$('t_track').value;
let name='';
if(mode==='ta')name=(a&&t)?a+' - '+t:(t||a||'');
else if(mode==='tta')name=(tr?tr.split('/')[0].padStart(2,'0')+'. ':'')+(t||'');
else if(mode==='t')name=t||'';
if(name)$('t_filename').value=name;
else toast('请先填写对应标签','err');
}
</script>
</body>
</html>"""
@app.get("/")
async def index():
return HTMLResponse(content=HTML)