Files
haunt-fm/src/haunt_fm/api/profiles.py

207 lines
7.1 KiB
Python
Raw Normal View History

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