207 lines
7.1 KiB
Python
207 lines
7.1 KiB
Python
|
|
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]}
|