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
+238
-10
No files matched your search
@@ -19,6 +19,9 @@ interface Clip {
|
||||
bpm?: number | null
|
||||
key?: string | null
|
||||
prompt_structure?: Record<string, string> | 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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ interface Clip {
|
||||
bpm?: number | null
|
||||
key?: string | null
|
||||
prompt_structure?: Record<string, string> | 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<HTMLInputElement>(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
|
||||
</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) */}
|
||||
<div>
|
||||
<label className="text-xs text-lyria-muted uppercase tracking-wider mb-1 block">Clip ID</label>
|
||||
@@ -406,4 +432,79 @@ export default function DetailPanel({ clip, isPlaying, currentClipId, onClose, o
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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