feat: DB-backed favorites replace localStorage

- New clip_favorites table (clip_id, user_id DEFAULT 'default', created_at)
- API: POST /clips/:id/favorite (toggle), GET /favorites, GET /clips/:id/favorite
- Frontend: loads favorites on mount from API, toggles via POST
- Removes localStorage dependency - favorites survive cache clears
- Ready for multi-user: just add real user_id when auth arrives
This commit is contained in:
rin.kagamine committed 2026-05-31 11:35:01 -04:00
1 parent ba4d4a95fe
commit b008a6790a
3 files changed
+110 -19

No files matched your search

+55 -1
View File
@@ -50,6 +50,15 @@ CREATE TABLE IF NOT EXISTS clips (
-- ALTER TABLE clips ADD COLUMN title TEXT; -- run separately if needed
CREATE INDEX IF NOT EXISTS idx_clips_created ON clips(created_at DESC);
-- clip_favorites: many-to-many join for user favorites (user_id defaults to 'default' until auth)
CREATE TABLE IF NOT EXISTS clip_favorites (
clip_id TEXT NOT NULL REFERENCES clips(id) ON DELETE CASCADE,
user_id TEXT NOT NULL DEFAULT 'default',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (clip_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_favorites_user ON clip_favorites(user_id, created_at DESC);
-- Migration: add artwork_filename column to existing databases
-- ALTER TABLE clips ADD COLUMN artwork_filename TEXT; -- run separately if needed
@@ -221,4 +230,49 @@ async def delete_clip(clip_id: str):
async def delete_all_clips():
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("DELETE FROM clips")
await db.commit()
await db.commit()
# ─── Favorites ───────────────────────────────────────────────────────────────
async def toggle_favorite(clip_id: str, user_id: str = "default") -> bool:
"""Toggle favorite status. Returns True if now favorited, False if removed."""
async with aiosqlite.connect(DB_PATH) as db:
# Check if already favorited
async with db.execute(
"SELECT 1 FROM clip_favorites WHERE clip_id = ? AND user_id = ?",
(clip_id, user_id)
) as cursor:
exists = await cursor.fetchone()
if exists:
await db.execute(
"DELETE FROM clip_favorites WHERE clip_id = ? AND user_id = ?",
(clip_id, user_id)
)
await db.commit()
return False
else:
await db.execute(
"INSERT INTO clip_favorites (clip_id, user_id) VALUES (?, ?)",
(clip_id, user_id)
)
await db.commit()
return True
async def get_favorites(user_id: str = "default") -> list[str]:
"""Get list of favorited clip IDs for a user."""
async with aiosqlite.connect(DB_PATH) as db:
async with db.execute(
"SELECT clip_id FROM clip_favorites WHERE user_id = ? ORDER BY created_at DESC",
(user_id,)
) as cursor:
rows = await cursor.fetchall()
return [row[0] for row in rows]
async def is_favorite(clip_id: str, user_id: str = "default") -> bool:
"""Check if a clip is favorited by a user."""
async with aiosqlite.connect(DB_PATH) as db:
async with db.execute(
"SELECT 1 FROM clip_favorites WHERE clip_id = ? AND user_id = ?",
(clip_id, user_id)
) as cursor:
return await cursor.fetchone() is not None
+30 -1
View File
@@ -10,7 +10,7 @@ from dotenv import load_dotenv
load_dotenv(Path(__file__).parent.parent / ".env")
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 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, toggle_favorite, get_favorites, is_favorite
from kithara_client import generate_clip, save_clip_file
from waveform import generate_waveform_svg, get_waveform_path
from analysis import analyze_clip
@@ -569,6 +569,35 @@ async def delete_all_clips_endpoint():
await delete_all_clips()
return {"deleted": len(all_clips)}
# ─── Favorites ─────────────────────────────────────────────────────────────────
@app.post("/api/clips/{clip_id}/favorite")
async def toggle_favorite_endpoint(clip_id: str):
"""Toggle favorite status for a clip. Returns {favorited: bool}."""
clip = await get_clip(clip_id)
if not clip:
raise HTTPException(status_code=404, detail="Clip not found")
favorited = await toggle_favorite(clip_id)
return {"favorited": favorited}
@app.get("/api/favorites")
async def get_favorites_endpoint():
"""Get list of favorited clip IDs for the current user."""
ids = await get_favorites()
return {"ids": ids}
@app.get("/api/clips/{clip_id}/favorite")
async def check_favorite_endpoint(clip_id: str):
"""Check if a clip is favorited."""
clip = await get_clip(clip_id)
if not clip:
raise HTTPException(status_code=404, detail="Clip not found")
is_fav = await is_favorite(clip_id)
return {"favorited": is_fav}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)