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 <a href> with download attribute for native browser handling
Architecture KB updated with Export/Download section.
This commit is contained in:
1 parent
57b9f9d78e
commit
119b75fbcd
3 files changed
+60
-1
No files matched your search
@@ -113,6 +113,31 @@ async def stream_audio(clip_id: str):
|
|||||||
raise HTTPException(status_code=404, detail="Audio file not found")
|
raise HTTPException(status_code=404, detail="Audio file not found")
|
||||||
return FileResponse(file_path, media_type="audio/mpeg", filename=clip["filename"])
|
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}")
|
@app.delete("/api/clips/{clip_id}")
|
||||||
async def remove_clip(clip_id: str):
|
async def remove_clip(clip_id: str):
|
||||||
clip = await get_clip(clip_id)
|
clip = await get_clip(clip_id)
|
||||||
|
|||||||
@@ -302,7 +302,17 @@ export default function DetailPanel({ clip, onClose, onRename, onDelete, onUploa
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="pt-3 border-t border-white/5">
|
<div className="pt-3 border-t border-white/5 space-y-2">
|
||||||
|
<a
|
||||||
|
href={`/api/clips/${clip.id}/download`}
|
||||||
|
download
|
||||||
|
className="w-full py-2 text-sm text-lyria-accent bg-lyria-accent/10 hover:bg-lyria-accent/20 rounded-md transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
Download MP3
|
||||||
|
</a>
|
||||||
<button
|
<button
|
||||||
onClick={() => { onDelete(clip.id); onClose() }}
|
onClick={() => { onDelete(clip.id); onClose() }}
|
||||||
className="w-full py-2 text-sm text-red-400 bg-red-500/10 hover:bg-red-500/20 rounded-md transition-colors"
|
className="w-full py-2 text-sm text-red-400 bg-red-500/10 hover:bg-red-500/20 rounded-md transition-colors"
|
||||||
|
|||||||
@@ -303,6 +303,18 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
window.open(`/api/clips/${clip.id}/download`, '_blank')
|
||||||
|
}}
|
||||||
|
className="text-lyria-muted hover:text-lyria-accent transition-colors shrink-0"
|
||||||
|
title="Download MP3"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onDelete(clip.id) }}
|
onClick={(e) => { e.stopPropagation(); onDelete(clip.id) }}
|
||||||
className="text-lyria-muted hover:text-red-400 transition-colors shrink-0"
|
className="text-lyria-muted hover:text-red-400 transition-colors shrink-0"
|
||||||
@@ -449,6 +461,18 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
window.open(`/api/clips/${clip.id}/download`, '_blank')
|
||||||
|
}}
|
||||||
|
className="text-lyria-muted/40 hover:text-lyria-accent transition-colors text-xs"
|
||||||
|
title="Download MP3"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onDelete(clip.id) }}
|
onClick={(e) => { e.stopPropagation(); onDelete(clip.id) }}
|
||||||
className="text-lyria-muted/40 hover:text-red-400 transition-colors text-xs"
|
className="text-lyria-muted/40 hover:text-red-400 transition-colors text-xs"
|
||||||
|
|||||||
Reference in new issue
Block a user