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 = """ MP3 Tag Editor

MP3 Tag Editor

在线编辑 MP3 标签 / 歌词 / 封面 / 文件名

点击或拖拽 MP3 文件到此处

支持 .mp3 格式

""" @app.get("/") async def index(): return HTMLResponse(content=HTML)