feat: Metadata fields (artist, album, comment) with ID3 tag export

Backend:
- Added artist, album, comment columns to clips DB (migration in init_db)
- New update_metadata() function in database.py
- New PATCH /api/clips/{id}/metadata endpoint
- Updated download endpoint to write ID3 tags using mutagen:
  * TIT2 (Title) — from clip title
  * TPE1 (Artist) — from clip artist
  * TALB (Album) — from clip album
  * COMM (Comment) — from clip comment
  * TBPM (BPM) — from detected BPM
  * TKEY (Key) — from detected key
  * TIT1 (Content Group) — prompt for reference
- Added mutagen==1.47.0 to requirements.txt
- Creates temporary copy with tags, original file untouched

Frontend:
- Clip interface updated with artist, album, comment fields
- New MetadataField component in DetailPanel:
  * Inline editing with click-to-edit
  * Input field with lyria-accent border
  * Enter to save, Escape to cancel, blur to save
  * Pencil icon on hover
  * Shows placeholder when empty
- App.tsx: handleUpdateMetadata() function, passed to DetailPanel
- Updates state across clips, currentClip, selectedClip

Architecture KB and Tasks KB updated.
This commit is contained in:
rin.kagamine committed 2026-05-29 22:55:01 -04:00
1 parent e687f7d457
commit a713488fb7
6 files changed
+238 -10

No files matched your search

+74 -8
View File
@@ -9,8 +9,8 @@ from dotenv import load_dotenv
# Load environment variables BEFORE importing lyria_client (it reads env at module level)
load_dotenv(Path(__file__).parent.parent / ".env")
from models import GenerateRequest, GenerateResponse, ClipList, RenameRequest, GenerateArtworkRequest, LYRIA_MODELS, DEFAULT_MODEL
from database import init_db, create_clip, get_clips, get_clip, delete_clip, update_clip_status, rename_clip, set_artwork, remove_artwork, delete_all_clips, set_analysis
from models import GenerateRequest, GenerateResponse, ClipList, RenameRequest, MetadataRequest, GenerateArtworkRequest, LYRIA_MODELS, DEFAULT_MODEL
from database import init_db, create_clip, get_clips, get_clip, delete_clip, update_clip_status, rename_clip, set_artwork, remove_artwork, delete_all_clips, set_analysis, update_metadata
from lyria_client import generate_clip, save_clip_file
from waveform import generate_waveform_svg, get_waveform_path
from analysis import analyze_clip
@@ -120,6 +120,9 @@ async def stream_audio(clip_id: str):
raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse(file_path, media_type="audio/mpeg", filename=clip["filename"])
from mutagen.mp3 import MP3
from mutagen.id3 import ID3, TIT1, TIT2, TPE1, TALB, COMM, TBPM, TKEY
@app.get("/api/clips/{clip_id}/download")
async def download_clip(clip_id: str):
clip = await get_clip(clip_id)
@@ -138,12 +141,66 @@ async def download_clip(clip_id: str):
safe_name = clip_id
download_filename = f"{safe_name}.mp3"
return FileResponse(
file_path,
media_type="audio/mpeg",
filename=download_filename,
content_disposition_type="attachment"
)
# Create a temporary copy with ID3 tags
import tempfile
import shutil
temp_file = tempfile.NamedTemporaryFile(suffix='.mp3', delete=False)
temp_path = Path(temp_file.name)
temp_file.close()
try:
# Copy original file
shutil.copy2(file_path, temp_path)
# Write ID3 tags
try:
audio = MP3(temp_path)
if audio.tags is None:
audio.add_tags()
# Title
if clip.get("title"):
audio.tags["TIT2"] = TIT2(encoding=3, text=clip["title"])
# Artist
if clip.get("artist"):
audio.tags["TPE1"] = TPE1(encoding=3, text=clip["artist"])
# Album
if clip.get("album"):
audio.tags["TALB"] = TALB(encoding=3, text=clip["album"])
# Comment
if clip.get("comment"):
audio.tags["COMM"] = COMM(encoding=3, lang='eng', desc='', text=clip["comment"])
# BPM
if clip.get("bpm"):
audio.tags["TBPM"] = TBPM(encoding=3, text=str(int(clip["bpm"])))
# Key
if clip.get("key"):
audio.tags["TKEY"] = TKEY(encoding=3, text=clip["key"])
# Also store the prompt in a custom tag for reference
if clip.get("prompt"):
audio.tags["TIT1"] = TIT1(encoding=3, text=clip["prompt"][:100])
audio.save()
except Exception as e:
print(f"Warning: Failed to write ID3 tags: {e}")
return FileResponse(
temp_path,
media_type="audio/mpeg",
filename=download_filename,
content_disposition_type="attachment"
)
except Exception as e:
if temp_path.exists():
temp_path.unlink()
raise HTTPException(status_code=500, detail=f"Failed to prepare download: {str(e)}")
@app.delete("/api/clips/{clip_id}")
async def remove_clip(clip_id: str):
@@ -377,6 +434,15 @@ async def rename_clip_endpoint(clip_id: str, request: RenameRequest):
updated = await get_clip(clip_id)
return updated
@app.patch("/api/clips/{clip_id}/metadata")
async def update_clip_metadata(clip_id: str, request: MetadataRequest):
clip = await get_clip(clip_id)
if not clip:
raise HTTPException(status_code=404, detail="Clip not found")
await update_metadata(clip_id, request.artist, request.album, request.comment)
updated = await get_clip(clip_id)
return updated
# ─── Admin Endpoints ──────────────────────────────────────────────
@app.get("/api/clips/{clip_id}/analysis")