| 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] |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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() |
|
|
| |
| 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] 在此粘贴歌词... [00:05.20] 支持 LRC 格式 也可以输入纯文本歌词"></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) |
|
|