Add named taste profiles for per-person recommendations

Named profiles allow each household member to get personalized
recommendations without polluting each other's taste. Includes
profile CRUD API, speaker→profile auto-attribution, recent listen
history endpoint, and profile param on all existing endpoints.
All endpoints backward compatible (no profile param = "default").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 19:14:34 -06:00
parent 1b739fbd20
commit 094621a9a8
14 changed files with 556 additions and 33 deletions

View File

@@ -0,0 +1,206 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from haunt_fm.db import get_session
from haunt_fm.models.track import (
ListenEvent,
Profile,
SpeakerProfileMapping,
TasteProfile,
)
router = APIRouter(prefix="/api/profiles")
class CreateProfileRequest(BaseModel):
name: str
display_name: str | None = None
class SetSpeakersRequest(BaseModel):
speakers: list[str]
async def _get_profile_or_404(session: AsyncSession, name: str) -> Profile:
result = await session.execute(select(Profile).where(Profile.name == name))
profile = result.scalar_one_or_none()
if profile is None:
raise HTTPException(status_code=404, detail=f"Profile '{name}' not found")
return profile
@router.get("/")
async def list_profiles(session: AsyncSession = Depends(get_session)):
"""List all profiles with stats."""
result = await session.execute(
select(
Profile.name,
Profile.display_name,
Profile.created_at,
func.count(ListenEvent.id).label("event_count"),
func.count(func.distinct(ListenEvent.track_id)).label("track_count"),
func.max(ListenEvent.listened_at).label("last_listen"),
)
.outerjoin(ListenEvent, ListenEvent.profile_id == Profile.id)
.group_by(Profile.id)
.order_by(Profile.created_at)
)
profiles = []
for row in result:
profiles.append({
"name": row.name,
"display_name": row.display_name,
"created_at": row.created_at.isoformat() if row.created_at else None,
"event_count": row.event_count,
"track_count": row.track_count,
"last_listen": row.last_listen.isoformat() if row.last_listen else None,
})
# Also count events with no profile_id (belong to "default")
unassigned = await session.execute(
select(func.count(ListenEvent.id)).where(ListenEvent.profile_id.is_(None))
)
unassigned_count = unassigned.scalar() or 0
# Add unassigned counts to default profile
for p in profiles:
if p["name"] == "default":
p["event_count"] += unassigned_count
break
return {"profiles": profiles}
@router.post("/", status_code=201)
async def create_profile(req: CreateProfileRequest, session: AsyncSession = Depends(get_session)):
"""Create a new profile."""
existing = await session.execute(select(Profile).where(Profile.name == req.name))
if existing.scalar_one_or_none() is not None:
raise HTTPException(status_code=409, detail=f"Profile '{req.name}' already exists")
profile = Profile(name=req.name, display_name=req.display_name)
session.add(profile)
await session.commit()
await session.refresh(profile)
return {
"name": profile.name,
"display_name": profile.display_name,
"created_at": profile.created_at.isoformat(),
}
@router.get("/{name}")
async def get_profile(name: str, session: AsyncSession = Depends(get_session)):
"""Get a single profile with stats."""
profile = await _get_profile_or_404(session, name)
# Event stats — include NULL profile_id events for "default"
if name == "default":
event_filter = (ListenEvent.profile_id == profile.id) | (ListenEvent.profile_id.is_(None))
else:
event_filter = ListenEvent.profile_id == profile.id
stats = await session.execute(
select(
func.count(ListenEvent.id).label("event_count"),
func.count(func.distinct(ListenEvent.track_id)).label("track_count"),
func.max(ListenEvent.listened_at).label("last_listen"),
).where(event_filter)
)
row = stats.one()
# Speaker mappings
speakers = await session.execute(
select(SpeakerProfileMapping.speaker_name)
.where(SpeakerProfileMapping.profile_id == profile.id)
)
# Taste profile status
taste = await session.execute(
select(TasteProfile).where(TasteProfile.profile_id == profile.id)
)
taste_profile = taste.scalar_one_or_none()
return {
"name": profile.name,
"display_name": profile.display_name,
"created_at": profile.created_at.isoformat(),
"event_count": row.event_count,
"track_count": row.track_count,
"last_listen": row.last_listen.isoformat() if row.last_listen else None,
"speakers": [r.speaker_name for r in speakers],
"taste_profile": {
"track_count": taste_profile.track_count,
"updated_at": taste_profile.updated_at.isoformat(),
} if taste_profile else None,
}
@router.delete("/{name}")
async def delete_profile(name: str, session: AsyncSession = Depends(get_session)):
"""Delete a profile, reassigning its events to default."""
if name == "default":
raise HTTPException(status_code=400, detail="Cannot delete the default profile")
profile = await _get_profile_or_404(session, name)
# Reassign listen events to NULL (i.e. default)
await session.execute(
ListenEvent.__table__.update()
.where(ListenEvent.profile_id == profile.id)
.values(profile_id=None)
)
# Delete speaker mappings
await session.execute(
delete(SpeakerProfileMapping).where(SpeakerProfileMapping.profile_id == profile.id)
)
# Delete taste profile for this profile
await session.execute(
delete(TasteProfile).where(TasteProfile.profile_id == profile.id)
)
await session.delete(profile)
await session.commit()
return {"ok": True, "deleted": name}
@router.put("/{name}/speakers")
async def set_speakers(name: str, req: SetSpeakersRequest, session: AsyncSession = Depends(get_session)):
"""Set speaker→profile mappings (replaces existing)."""
profile = await _get_profile_or_404(session, name)
# Remove existing mappings for this profile
await session.execute(
delete(SpeakerProfileMapping).where(SpeakerProfileMapping.profile_id == profile.id)
)
# Create new mappings
for speaker in req.speakers:
# Check if this speaker is already mapped to another profile
existing = await session.execute(
select(SpeakerProfileMapping).where(SpeakerProfileMapping.speaker_name == speaker)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=409,
detail=f"Speaker '{speaker}' is already mapped to another profile",
)
session.add(SpeakerProfileMapping(speaker_name=speaker, profile_id=profile.id))
await session.commit()
return {"ok": True, "profile": name, "speakers": req.speakers}
@router.get("/{name}/speakers")
async def get_speakers(name: str, session: AsyncSession = Depends(get_session)):
"""List speaker mappings for a profile."""
profile = await _get_profile_or_404(session, name)
result = await session.execute(
select(SpeakerProfileMapping.speaker_name)
.where(SpeakerProfileMapping.profile_id == profile.id)
)
return {"profile": name, "speakers": [r.speaker_name for r in result]}