diff --git a/backend/requirements.txt b/backend/requirements.txt index 89d5ae0..1d59efc 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,3 +9,4 @@ numpy==2.2.0 librosa==0.10.2 soundfile==0.12.1 google-genai==1.0.0 +mutagen==1.47.0 diff --git a/backend/src/database.py b/backend/src/database.py index 210d51d..fb0f4c6 100644 --- a/backend/src/database.py +++ b/backend/src/database.py @@ -83,6 +83,21 @@ async def init_db(): await db.execute("ALTER TABLE clips ADD COLUMN prompt_structure TEXT") except Exception: pass # Column already exists + # Migration: add artist column if it doesn't exist + try: + await db.execute("ALTER TABLE clips ADD COLUMN artist TEXT") + except Exception: + pass # Column already exists + # Migration: add album column if it doesn't exist + try: + await db.execute("ALTER TABLE clips ADD COLUMN album TEXT") + except Exception: + pass # Column already exists + # Migration: add comment column if it doesn't exist + try: + await db.execute("ALTER TABLE clips ADD COLUMN comment TEXT") + except Exception: + pass # Column already exists await db.commit() async def create_clip(prompt: str, model: str, duration: int, prompt_structure: str = None) -> str: @@ -161,6 +176,14 @@ async def rename_clip(clip_id: str, title: str): ) await db.commit() +async def update_metadata(clip_id: str, artist: str = None, album: str = None, comment: str = None): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + "UPDATE clips SET artist = ?, album = ?, comment = ? WHERE id = ?", + (artist, album, comment, clip_id) + ) + await db.commit() + async def set_artwork(clip_id: str, filename: str): async with aiosqlite.connect(DB_PATH) as db: await db.execute( diff --git a/backend/src/main.py b/backend/src/main.py index b0b4b5c..34fe25e 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -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") diff --git a/backend/src/models.py b/backend/src/models.py index 3286a65..58f8194 100644 --- a/backend/src/models.py +++ b/backend/src/models.py @@ -81,14 +81,21 @@ class Clip(BaseModel): prompt_structure: Optional[dict] = None bpm: Optional[float] = None key: Optional[str] = None + artist: Optional[str] = None + album: Optional[str] = None + comment: Optional[str] = None class RenameRequest(BaseModel): title: str +class MetadataRequest(BaseModel): + artist: Optional[str] = None + album: Optional[str] = None + comment: Optional[str] = None + class GenerateArtworkRequest(BaseModel): prompt: str class ClipList(BaseModel): clips: list[Clip] - total: int total: int \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 88d7967..32d483b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,6 +19,9 @@ interface Clip { bpm?: number | null key?: string | null prompt_structure?: Record | null + artist?: string | null + album?: string | null + comment?: string | null created_at: string } @@ -187,6 +190,32 @@ function App() { } } + const handleUpdateMetadata = async (id: string, artist: string, album: string, comment: string) => { + try { + const res = await fetch(`/api/clips/${id}/metadata`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist, album, comment }), + }) + if (res.ok) { + const updated = await res.json() + setClips(prev => prev.map(c => c.id === updated.id ? updated : c)) + if (currentClip?.id === updated.id) { + setCurrentClip(updated) + } + if (selectedClip?.id === updated.id) { + setSelectedClip(updated) + } + } else { + const err = await res.json() + alert(err.detail || 'Failed to update metadata') + } + } catch (err) { + console.error('Failed to update metadata:', err) + alert('Failed to update metadata') + } + } + const handleClearWaveforms = async () => { try { await fetch('/api/admin/clear-waveforms', { method: 'POST' }) @@ -275,6 +304,7 @@ function App() { onPlay={handlePlay} onTogglePlay={() => setIsPlaying(!isPlaying)} onGenerateArtwork={handleGenerateArtwork} + onUpdateMetadata={handleUpdateMetadata} /> )} diff --git a/frontend/src/components/DetailPanel.tsx b/frontend/src/components/DetailPanel.tsx index 221dea4..0efe832 100644 --- a/frontend/src/components/DetailPanel.tsx +++ b/frontend/src/components/DetailPanel.tsx @@ -14,6 +14,9 @@ interface Clip { bpm?: number | null key?: string | null prompt_structure?: Record | null + artist?: string | null + album?: string | null + comment?: string | null created_at: string } @@ -29,6 +32,7 @@ interface Props { onPlay?: (clip: Clip) => void onTogglePlay?: () => void onGenerateArtwork?: (clip: Clip) => void + onUpdateMetadata?: (id: string, artist: string, album: string, comment: string) => void } const modelName = (modelId: string) => { @@ -39,7 +43,7 @@ const modelName = (modelId: string) => { return names[modelId] || modelId } -export default function DetailPanel({ clip, isPlaying, currentClipId, onClose, onRename, onDelete, onUploadArtwork, onRemoveArtwork, onPlay, onTogglePlay, onGenerateArtwork }: Props) { +export default function DetailPanel({ clip, isPlaying, currentClipId, onClose, onRename, onDelete, onUploadArtwork, onRemoveArtwork, onPlay, onTogglePlay, onGenerateArtwork, onUpdateMetadata }: Props) { const artworkInputRef = useRef(null) const [isEditingTitle, setIsEditingTitle] = useState(false) const [editTitle, setEditTitle] = useState(clip.title || '') @@ -377,6 +381,28 @@ export default function DetailPanel({ clip, isPlaying, currentClipId, onClose, o + {/* Metadata Fields (Artist, Album, Comment) */} +
+ onUpdateMetadata?.(clip.id, val, clip.album || '', clip.comment || '')} + /> + onUpdateMetadata?.(clip.id, clip.artist || '', val, clip.comment || '')} + /> + onUpdateMetadata?.(clip.id, clip.artist || '', clip.album || '', val)} + /> +
+ {/* ID (for reference) */}
@@ -406,4 +432,79 @@ export default function DetailPanel({ clip, isPlaying, currentClipId, onClose, o
) +} + +// ─── Inline Metadata Field Component ─────────────────────────────────────── + +function MetadataField({ label, value, placeholder, onSave }: { + label: string + value: string + placeholder: string + onSave: (val: string) => void +}) { + const [editing, setEditing] = useState(false) + const [editValue, setEditValue] = useState(value) + const inputRef = useRef(null) + + useEffect(() => { + if (editing && inputRef.current) { + inputRef.current.focus() + inputRef.current.select() + } + }, [editing]) + + const save = () => { + onSave(editValue.trim()) + setEditing(false) + } + + const cancel = () => { + setEditValue(value) + setEditing(false) + } + + if (editing) { + return ( +
+ +
+ setEditValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') save() + if (e.key === 'Escape') cancel() + }} + onBlur={save} + placeholder={placeholder} + className="flex-1 bg-black/30 border border-lyria-accent rounded-md px-3 py-1.5 text-sm text-white placeholder:text-lyria-muted/40 focus:outline-none" + /> +
+
+ ) + } + + return ( +
setEditing(true)} + > + +
+ + {value || placeholder} + + + + +
+
+ ) } \ No newline at end of file