feat: genre field in DetailPanel and ID3 TCON tag

- Added genre column to clips table (auto-migration)
- Genre field in DetailPanel metadata section (between Album and Comment)
- Genre saved via PATCH /api/clips/:id/metadata
- Genre embedded in ID3 TCON tag on download
- Placeholder text: 'e.g. Hardcore Techno, Phonk, Dark Ambient...'
This commit is contained in:
rin.kagamine committed 2026-05-31 11:18:37 -04:00
1 parent 1903d8bd2a
commit bef707f9a4
5 files changed
+29 -12

No files matched your search

+8 -3
View File
@@ -98,6 +98,11 @@ async def init_db():
await db.execute("ALTER TABLE clips ADD COLUMN comment TEXT") await db.execute("ALTER TABLE clips ADD COLUMN comment TEXT")
except Exception: except Exception:
pass # Column already exists pass # Column already exists
# Migration: add genre column if it doesn't exist
try:
await db.execute("ALTER TABLE clips ADD COLUMN genre 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:
@@ -176,11 +181,11 @@ 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 def update_metadata(clip_id: str, artist: str = None, album: str = None, comment: str = None, genre: str = None):
async with aiosqlite.connect(DB_PATH) as db: async with aiosqlite.connect(DB_PATH) as db:
await db.execute( await db.execute(
"UPDATE clips SET artist = ?, album = ?, comment = ? WHERE id = ?", "UPDATE clips SET artist = ?, album = ?, comment = ?, genre = ? WHERE id = ?",
(artist, album, comment, clip_id) (artist, album, comment, genre, clip_id)
) )
await db.commit() await db.commit()
+6 -2
View File
@@ -135,7 +135,7 @@ async def stream_audio(clip_id: str):
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.mp3 import MP3
from mutagen.id3 import ID3, TIT1, TIT2, TPE1, TALB, COMM, TBPM, TKEY, APIC, PictureType from mutagen.id3 import ID3, TIT1, TIT2, TPE1, TALB, COMM, TBPM, TKEY, APIC, PictureType, TCON
@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):
@@ -197,6 +197,10 @@ async def download_clip(clip_id: str):
if clip.get("key"): if clip.get("key"):
audio.tags["TKEY"] = TKEY(encoding=3, text=clip["key"]) audio.tags["TKEY"] = TKEY(encoding=3, text=clip["key"])
# Genre
if clip.get("genre"):
audio.tags["TCON"] = TCON(encoding=3, text=clip["genre"])
# Album art # Album art
if clip.get("artwork_filename"): if clip.get("artwork_filename"):
artwork_path = ARTWORK_DIR / clip["artwork_filename"] artwork_path = ARTWORK_DIR / clip["artwork_filename"]
@@ -496,7 +500,7 @@ async def update_clip_metadata(clip_id: str, request: MetadataRequest):
clip = await get_clip(clip_id) clip = await get_clip(clip_id)
if not clip: if not clip:
raise HTTPException(status_code=404, detail="Clip not found") raise HTTPException(status_code=404, detail="Clip not found")
await update_metadata(clip_id, request.artist, request.album, request.comment) await update_metadata(clip_id, request.artist, request.album, request.comment, request.genre)
updated = await get_clip(clip_id) updated = await get_clip(clip_id)
return updated return updated
+1
View File
@@ -92,6 +92,7 @@ class MetadataRequest(BaseModel):
artist: Optional[str] = None artist: Optional[str] = None
album: Optional[str] = None album: Optional[str] = None
comment: Optional[str] = None comment: Optional[str] = None
genre: Optional[str] = None
class GenerateArtworkRequest(BaseModel): class GenerateArtworkRequest(BaseModel):
prompt: str prompt: str
+2 -2
View File
@@ -190,12 +190,12 @@ function App() {
} }
} }
const handleUpdateMetadata = async (id: string, artist: string, album: string, comment: string) => { const handleUpdateMetadata = async (id: string, artist: string, album: string, genre: string, comment: string) => {
try { try {
const res = await fetch(`/api/clips/${id}/metadata`, { const res = await fetch(`/api/clips/${id}/metadata`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist, album, comment }), body: JSON.stringify({ artist, album, genre, comment }),
}) })
if (res.ok) { if (res.ok) {
const updated = await res.json() const updated = await res.json()
+12 -5
View File
@@ -17,6 +17,7 @@ interface Clip {
prompt_structure?: Record<string, string> | null prompt_structure?: Record<string, string> | null
artist?: string | null artist?: string | null
album?: string | null album?: string | null
genre?: string | null
comment?: string | null comment?: string | null
created_at: string created_at: string
} }
@@ -33,7 +34,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 onUpdateMetadata?: (id: string, artist: string, album: string, genre: string, comment: string) => void
} }
const modelName = (modelId: string) => { const modelName = (modelId: string) => {
@@ -383,25 +384,31 @@ export default function DetailPanel({ clip, isPlaying, currentClipId, onClose, o
</div> </div>
</div> </div>
{/* Metadata Fields (Artist, Album, Comment) */} {/* Metadata Fields (Artist, Album, Genre, Comment) */}
<div className="space-y-3"> <div className="space-y-3">
<MetadataField <MetadataField
label="Artist" label="Artist"
value={clip.artist || ''} value={clip.artist || ''}
placeholder="Artist name..." placeholder="Artist name..."
onSave={(val) => onUpdateMetadata?.(clip.id, val, clip.album || '', clip.comment || '')} onSave={(val) => onUpdateMetadata?.(clip.id, val, clip.album || '', clip.genre || '', clip.comment || '')}
/> />
<MetadataField <MetadataField
label="Album" label="Album"
value={clip.album || ''} value={clip.album || ''}
placeholder="Album name..." placeholder="Album name..."
onSave={(val) => onUpdateMetadata?.(clip.id, clip.artist || '', val, clip.comment || '')} onSave={(val) => onUpdateMetadata?.(clip.id, clip.artist || '', val, clip.genre || '', clip.comment || '')}
/>
<MetadataField
label="Genre"
value={clip.genre || ''}
placeholder="e.g. Hardcore Techno, Phonk, Dark Ambient..."
onSave={(val) => onUpdateMetadata?.(clip.id, clip.artist || '', clip.album || '', val, clip.comment || '')}
/> />
<MetadataField <MetadataField
label="Comment" label="Comment"
value={clip.comment || ''} value={clip.comment || ''}
placeholder="Notes, credits, etc..." placeholder="Notes, credits, etc..."
onSave={(val) => onUpdateMetadata?.(clip.id, clip.artist || '', clip.album || '', val)} onSave={(val) => onUpdateMetadata?.(clip.id, clip.artist || '', clip.album || '', clip.genre || '', val)}
/> />
</div> </div>