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:
206
src/haunt_fm/api/profiles.py
Normal file
206
src/haunt_fm/api/profiles.py
Normal 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]}
|
||||
Reference in New Issue
Block a user