New endpoint POST /api/playlists/{id}/fix that analyzes a playlist
using Claude AI to identify outlier tracks that don't match the
overall vibe, and suggests better-fitting replacements.
Frontend shows results with warm amber cards for outliers and green
cards for replacements, with dismissible suggestions and visual
highlighting of flagged tracks in the track list.
133 lines
3.8 KiB
Python
133 lines
3.8 KiB
Python
import json
|
|
|
|
import anthropic
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.config import settings
|
|
from app.core.database import get_db
|
|
from app.core.security import get_current_user
|
|
from app.models.playlist import Playlist
|
|
from app.models.track import Track
|
|
from app.models.user import User
|
|
from app.services.recommender import build_taste_profile
|
|
|
|
router = APIRouter(prefix="/playlists", tags=["playlists"])
|
|
|
|
|
|
class PlaylistFixRequest(BaseModel):
|
|
count: int = 5
|
|
|
|
|
|
class OutlierTrack(BaseModel):
|
|
track_number: int
|
|
artist: str
|
|
title: str
|
|
reason: str
|
|
|
|
|
|
class ReplacementTrack(BaseModel):
|
|
title: str
|
|
artist: str
|
|
album: str | None = None
|
|
reason: str
|
|
|
|
|
|
class PlaylistFixResponse(BaseModel):
|
|
playlist_vibe: str
|
|
outliers: list[OutlierTrack]
|
|
replacements: list[ReplacementTrack]
|
|
|
|
|
|
@router.post("/{playlist_id}/fix", response_model=PlaylistFixResponse)
|
|
async def fix_playlist(
|
|
playlist_id: int,
|
|
data: PlaylistFixRequest = PlaylistFixRequest(),
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
# Load playlist
|
|
result = await db.execute(
|
|
select(Playlist).where(Playlist.id == playlist_id, Playlist.user_id == user.id)
|
|
)
|
|
playlist = result.scalar_one_or_none()
|
|
if not playlist:
|
|
raise HTTPException(status_code=404, detail="Playlist not found")
|
|
|
|
# Load tracks
|
|
result = await db.execute(
|
|
select(Track).where(Track.playlist_id == playlist.id)
|
|
)
|
|
tracks = list(result.scalars().all())
|
|
if not tracks:
|
|
raise HTTPException(status_code=400, detail="Playlist has no tracks")
|
|
|
|
# Build taste profile
|
|
taste_profile = build_taste_profile(tracks)
|
|
|
|
# Build numbered track list
|
|
track_list = "\n".join(
|
|
f"{i + 1}. {t.artist} - {t.title}" for i, t in enumerate(tracks)
|
|
)
|
|
|
|
count = min(max(data.count, 1), 10)
|
|
|
|
prompt = f"""You are Vynl, a music playlist curator. Analyze this playlist and identify tracks that don't fit the overall vibe.
|
|
|
|
Playlist: {playlist.name}
|
|
Taste profile: {json.dumps(taste_profile, indent=2)}
|
|
|
|
Tracks:
|
|
{track_list}
|
|
|
|
Analyze the playlist and respond with a JSON object:
|
|
{{
|
|
"playlist_vibe": "A 1-2 sentence description of the overall playlist vibe/mood",
|
|
"outliers": [
|
|
{{
|
|
"track_number": 1,
|
|
"artist": "...",
|
|
"title": "...",
|
|
"reason": "Why this track doesn't fit the playlist vibe"
|
|
}}
|
|
],
|
|
"replacements": [
|
|
{{
|
|
"title": "...",
|
|
"artist": "...",
|
|
"album": "...",
|
|
"reason": "Why this fits better"
|
|
}}
|
|
]
|
|
}}
|
|
|
|
Identify up to {count} outlier tracks. For each outlier, suggest a replacement that fits the playlist vibe better. Focus on maintaining sonic cohesion — same energy, tempo range, and mood.
|
|
Return ONLY the JSON object."""
|
|
|
|
# Call Claude API
|
|
client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)
|
|
message = client.messages.create(
|
|
model="claude-haiku-4-5-20251001",
|
|
max_tokens=2000,
|
|
messages=[{"role": "user", "content": prompt}],
|
|
)
|
|
|
|
response_text = message.content[0].text.strip()
|
|
# Handle potential markdown code blocks
|
|
if response_text.startswith("```"):
|
|
response_text = response_text.split("\n", 1)[1]
|
|
response_text = response_text.rsplit("```", 1)[0]
|
|
|
|
try:
|
|
fix_data = json.loads(response_text)
|
|
except json.JSONDecodeError:
|
|
raise HTTPException(status_code=500, detail="Failed to parse AI response")
|
|
|
|
return PlaylistFixResponse(
|
|
playlist_vibe=fix_data.get("playlist_vibe", ""),
|
|
outliers=[OutlierTrack(**o) for o in fix_data.get("outliers", [])],
|
|
replacements=[ReplacementTrack(**r) for r in fix_data.get("replacements", [])],
|
|
)
|