From 119b75fbcdf2795b46496f851ca1c470361e5181 Mon Sep 17 00:00:00 2001 From: Kagamine Rin Date: Fri, 29 May 2026 21:32:41 -0400 Subject: [PATCH] feat: MP3 download/export capability Backend: - New endpoint: GET /api/clips/{id}/download - Returns MP3 with Content-Disposition: attachment header - Filename derived from clip title (sanitized) or prompt, falls back to clip ID - Sanitization: alphanumeric + spaces/hyphens/underscores only, max 60 chars - Uses FastAPI FileResponse with media_type=audio/mpeg Frontend: - Gallery ribbons: Download button (downward arrow icon) next to delete button - Uses window.open() to trigger browser download - Hover: lyria-accent color - Gallery tiles: Download button in tile footer action row - Small icon (w-3 h-3), same hover behavior - DetailPanel: Full-width 'Download MP3' button above Delete Track - lyria-accent styled, with download icon - Uses with download attribute for native browser handling Architecture KB updated with Export/Download section. --- backend/src/main.py | 25 +++++++++++++++++++++++++ frontend/src/components/DetailPanel.tsx | 12 +++++++++++- frontend/src/components/Gallery.tsx | 24 ++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/backend/src/main.py b/backend/src/main.py index 34528cc..af5c231 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -113,6 +113,31 @@ 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"]) +@app.get("/api/clips/{clip_id}/download") +async def download_clip(clip_id: str): + clip = await get_clip(clip_id) + if not clip: + raise HTTPException(status_code=404, detail="Clip not found") + file_path = CLIPS_DIR / clip["filename"] + if not file_path.exists(): + raise HTTPException(status_code=404, detail="Audio file not found") + + # Build download filename from title or prompt + base_name = clip.get("title") or clip["prompt"] + # Sanitize for filesystem + safe_name = "".join(c for c in base_name if c.isalnum() or c in " -_").strip() + safe_name = safe_name[:60] # Limit length + if not safe_name: + 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" + ) + @app.delete("/api/clips/{clip_id}") async def remove_clip(clip_id: str): clip = await get_clip(clip_id) diff --git a/frontend/src/components/DetailPanel.tsx b/frontend/src/components/DetailPanel.tsx index cdef05a..54fa4bc 100644 --- a/frontend/src/components/DetailPanel.tsx +++ b/frontend/src/components/DetailPanel.tsx @@ -302,7 +302,17 @@ export default function DetailPanel({ clip, onClose, onRename, onDelete, onUploa {/* Actions */} -
+
+ + + + + Download MP3 + + +