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]}