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:
1 parent
e687f7d457
commit
a713488fb7
6 files changed
+233
-5
No files matched your search
@@ -9,3 +9,4 @@ numpy==2.2.0
|
|||||||
librosa==0.10.2
|
librosa==0.10.2
|
||||||
soundfile==0.12.1
|
soundfile==0.12.1
|
||||||
google-genai==1.0.0
|
google-genai==1.0.0
|
||||||
|
mutagen==1.47.0
|
||||||
@@ -83,6 +83,21 @@ async def init_db():
|
|||||||
await db.execute("ALTER TABLE clips ADD COLUMN prompt_structure TEXT")
|
await db.execute("ALTER TABLE clips ADD COLUMN prompt_structure TEXT")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Column already exists
|
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()
|
await db.commit()
|
||||||
|
|
||||||
async def create_clip(prompt: str, model: str, duration: int, prompt_structure: str = None) -> str:
|
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()
|
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 def set_artwork(clip_id: str, filename: str):
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
|
|||||||
+69
-3
@@ -9,8 +9,8 @@ from dotenv import load_dotenv
|
|||||||
# Load environment variables BEFORE importing lyria_client (it reads env at module level)
|
# Load environment variables BEFORE importing lyria_client (it reads env at module level)
|
||||||
load_dotenv(Path(__file__).parent.parent / ".env")
|
load_dotenv(Path(__file__).parent.parent / ".env")
|
||||||
|
|
||||||
from models import GenerateRequest, GenerateResponse, ClipList, RenameRequest, GenerateArtworkRequest, LYRIA_MODELS, DEFAULT_MODEL
|
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
|
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 lyria_client import generate_clip, save_clip_file
|
||||||
from waveform import generate_waveform_svg, get_waveform_path
|
from waveform import generate_waveform_svg, get_waveform_path
|
||||||
from analysis import analyze_clip
|
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")
|
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"])
|
||||||
|
|
||||||
|
from mutagen.mp3 import MP3
|
||||||
|
from mutagen.id3 import ID3, TIT1, TIT2, TPE1, TALB, COMM, TBPM, TKEY
|
||||||
|
|
||||||
@app.get("/api/clips/{clip_id}/download")
|
@app.get("/api/clips/{clip_id}/download")
|
||||||
async def download_clip(clip_id: str):
|
async def download_clip(clip_id: str):
|
||||||
clip = await get_clip(clip_id)
|
clip = await get_clip(clip_id)
|
||||||
@@ -138,12 +141,66 @@ async def download_clip(clip_id: str):
|
|||||||
safe_name = clip_id
|
safe_name = clip_id
|
||||||
download_filename = f"{safe_name}.mp3"
|
download_filename = f"{safe_name}.mp3"
|
||||||
|
|
||||||
|
# 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(
|
return FileResponse(
|
||||||
file_path,
|
temp_path,
|
||||||
media_type="audio/mpeg",
|
media_type="audio/mpeg",
|
||||||
filename=download_filename,
|
filename=download_filename,
|
||||||
content_disposition_type="attachment"
|
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}")
|
@app.delete("/api/clips/{clip_id}")
|
||||||
async def remove_clip(clip_id: str):
|
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)
|
updated = await get_clip(clip_id)
|
||||||
return updated
|
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 ──────────────────────────────────────────────
|
# ─── Admin Endpoints ──────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/clips/{clip_id}/analysis")
|
@app.get("/api/clips/{clip_id}/analysis")
|
||||||
|
|||||||
@@ -81,14 +81,21 @@ class Clip(BaseModel):
|
|||||||
prompt_structure: Optional[dict] = None
|
prompt_structure: Optional[dict] = None
|
||||||
bpm: Optional[float] = None
|
bpm: Optional[float] = None
|
||||||
key: Optional[str] = None
|
key: Optional[str] = None
|
||||||
|
artist: Optional[str] = None
|
||||||
|
album: Optional[str] = None
|
||||||
|
comment: Optional[str] = None
|
||||||
|
|
||||||
class RenameRequest(BaseModel):
|
class RenameRequest(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
|
|
||||||
|
class MetadataRequest(BaseModel):
|
||||||
|
artist: Optional[str] = None
|
||||||
|
album: Optional[str] = None
|
||||||
|
comment: Optional[str] = None
|
||||||
|
|
||||||
class GenerateArtworkRequest(BaseModel):
|
class GenerateArtworkRequest(BaseModel):
|
||||||
prompt: str
|
prompt: str
|
||||||
|
|
||||||
class ClipList(BaseModel):
|
class ClipList(BaseModel):
|
||||||
clips: list[Clip]
|
clips: list[Clip]
|
||||||
total: int
|
total: int
|
||||||
total: int
|
|
||||||
@@ -19,6 +19,9 @@ interface Clip {
|
|||||||
bpm?: number | null
|
bpm?: number | null
|
||||||
key?: string | null
|
key?: string | null
|
||||||
prompt_structure?: Record<string, string> | null
|
prompt_structure?: Record<string, string> | null
|
||||||
|
artist?: string | null
|
||||||
|
album?: string | null
|
||||||
|
comment?: string | null
|
||||||
created_at: string
|
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 () => {
|
const handleClearWaveforms = async () => {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/admin/clear-waveforms', { method: 'POST' })
|
await fetch('/api/admin/clear-waveforms', { method: 'POST' })
|
||||||
@@ -275,6 +304,7 @@ function App() {
|
|||||||
onPlay={handlePlay}
|
onPlay={handlePlay}
|
||||||
onTogglePlay={() => setIsPlaying(!isPlaying)}
|
onTogglePlay={() => setIsPlaying(!isPlaying)}
|
||||||
onGenerateArtwork={handleGenerateArtwork}
|
onGenerateArtwork={handleGenerateArtwork}
|
||||||
|
onUpdateMetadata={handleUpdateMetadata}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ interface Clip {
|
|||||||
bpm?: number | null
|
bpm?: number | null
|
||||||
key?: string | null
|
key?: string | null
|
||||||
prompt_structure?: Record<string, string> | null
|
prompt_structure?: Record<string, string> | null
|
||||||
|
artist?: string | null
|
||||||
|
album?: string | null
|
||||||
|
comment?: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +32,7 @@ interface Props {
|
|||||||
onPlay?: (clip: Clip) => void
|
onPlay?: (clip: Clip) => void
|
||||||
onTogglePlay?: () => void
|
onTogglePlay?: () => void
|
||||||
onGenerateArtwork?: (clip: Clip) => void
|
onGenerateArtwork?: (clip: Clip) => void
|
||||||
|
onUpdateMetadata?: (id: string, artist: string, album: string, comment: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelName = (modelId: string) => {
|
const modelName = (modelId: string) => {
|
||||||
@@ -39,7 +43,7 @@ const modelName = (modelId: string) => {
|
|||||||
return names[modelId] || modelId
|
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<HTMLInputElement>(null)
|
const artworkInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [isEditingTitle, setIsEditingTitle] = useState(false)
|
const [isEditingTitle, setIsEditingTitle] = useState(false)
|
||||||
const [editTitle, setEditTitle] = useState(clip.title || '')
|
const [editTitle, setEditTitle] = useState(clip.title || '')
|
||||||
@@ -377,6 +381,28 @@ export default function DetailPanel({ clip, isPlaying, currentClipId, onClose, o
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata Fields (Artist, Album, Comment) */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<MetadataField
|
||||||
|
label="Artist"
|
||||||
|
value={clip.artist || ''}
|
||||||
|
placeholder="Artist name..."
|
||||||
|
onSave={(val) => onUpdateMetadata?.(clip.id, val, clip.album || '', clip.comment || '')}
|
||||||
|
/>
|
||||||
|
<MetadataField
|
||||||
|
label="Album"
|
||||||
|
value={clip.album || ''}
|
||||||
|
placeholder="Album name..."
|
||||||
|
onSave={(val) => onUpdateMetadata?.(clip.id, clip.artist || '', val, clip.comment || '')}
|
||||||
|
/>
|
||||||
|
<MetadataField
|
||||||
|
label="Comment"
|
||||||
|
value={clip.comment || ''}
|
||||||
|
placeholder="Notes, credits, etc..."
|
||||||
|
onSave={(val) => onUpdateMetadata?.(clip.id, clip.artist || '', clip.album || '', val)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ID (for reference) */}
|
{/* ID (for reference) */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-lyria-muted uppercase tracking-wider mb-1 block">Clip ID</label>
|
<label className="text-xs text-lyria-muted uppercase tracking-wider mb-1 block">Clip ID</label>
|
||||||
@@ -407,3 +433,78 @@ 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<HTMLInputElement>(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 (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-lyria-muted uppercase tracking-wider block mb-1">{label}</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group cursor-pointer"
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
>
|
||||||
|
<label className="text-xs text-lyria-muted uppercase tracking-wider block mb-1">{label}</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-sm ${value ? 'text-white' : 'text-lyria-muted/40 italic'}`}>
|
||||||
|
{value || placeholder}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className="w-3 h-3 text-lyria-muted/40 group-hover:text-lyria-accent transition-colors"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user