Add Fix My Playlist feature
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.
This commit is contained in:
132
backend/app/api/endpoints/playlist_fix.py
Normal file
132
backend/app/api/endpoints/playlist_fix.py
Normal file
@@ -0,0 +1,132 @@
|
||||
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", [])],
|
||||
)
|
||||
@@ -2,7 +2,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.config import settings
|
||||
from app.api.endpoints import auth, bandcamp, billing, lastfm, manual_import, playlists, profile, recommendations, youtube_music
|
||||
from app.api.endpoints import auth, bandcamp, billing, lastfm, manual_import, playlist_fix, playlists, profile, recommendations, youtube_music
|
||||
|
||||
app = FastAPI(title="Vynl API", version="1.0.0", redirect_slashes=False)
|
||||
|
||||
@@ -17,6 +17,7 @@ app.add_middleware(
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(billing.router, prefix="/api")
|
||||
app.include_router(playlists.router, prefix="/api")
|
||||
app.include_router(playlist_fix.router, prefix="/api")
|
||||
app.include_router(recommendations.router, prefix="/api")
|
||||
app.include_router(youtube_music.router, prefix="/api")
|
||||
app.include_router(manual_import.router, prefix="/api")
|
||||
|
||||
@@ -97,6 +97,7 @@ export interface RecommendationItem {
|
||||
image_url: string | null
|
||||
spotify_url: string | null
|
||||
bandcamp_url: string | null
|
||||
youtube_url: string | null
|
||||
reason: string
|
||||
saved: boolean
|
||||
disliked: boolean
|
||||
@@ -175,6 +176,7 @@ export const generateRecommendations = (
|
||||
mode?: string,
|
||||
adventurousness?: number,
|
||||
exclude?: string,
|
||||
count?: number,
|
||||
) =>
|
||||
api.post<RecommendationResponse>('/recommendations/generate', {
|
||||
playlist_id: playlistId,
|
||||
@@ -183,6 +185,7 @@ export const generateRecommendations = (
|
||||
mode: mode || 'discover',
|
||||
adventurousness: adventurousness ?? 3,
|
||||
exclude: exclude || undefined,
|
||||
count: count ?? 5,
|
||||
}).then((r) => r.data)
|
||||
|
||||
export const getRecommendationHistory = () =>
|
||||
@@ -278,6 +281,30 @@ export async function getBandcampEmbed(url: string): Promise<BandcampEmbed> {
|
||||
return data
|
||||
}
|
||||
|
||||
// Playlist Fix
|
||||
export interface OutlierTrack {
|
||||
track_number: number
|
||||
artist: string
|
||||
title: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface ReplacementTrack {
|
||||
title: string
|
||||
artist: string
|
||||
album: string | null
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface PlaylistFixResponse {
|
||||
playlist_vibe: string
|
||||
outliers: OutlierTrack[]
|
||||
replacements: ReplacementTrack[]
|
||||
}
|
||||
|
||||
export const fixPlaylist = (playlistId: string) =>
|
||||
api.post<PlaylistFixResponse>(`/playlists/${playlistId}/fix`).then((r) => r.data)
|
||||
|
||||
// Taste Profile
|
||||
export const getTasteProfile = () =>
|
||||
api.get<TasteProfileResponse>('/profile/taste').then((r) => r.data)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { ArrowLeft, Loader2, Music, Clock, Sparkles, Trash2 } from 'lucide-react'
|
||||
import { getPlaylist, deletePlaylist, type PlaylistDetailResponse } from '../lib/api'
|
||||
import { ArrowLeft, Loader2, Music, Clock, Sparkles, Trash2, Wand2, AlertTriangle, CheckCircle2, X } from 'lucide-react'
|
||||
import { getPlaylist, deletePlaylist, fixPlaylist, type PlaylistDetailResponse, type PlaylistFixResponse } from '../lib/api'
|
||||
import TasteProfile from '../components/TasteProfile'
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
@@ -17,6 +17,10 @@ export default function PlaylistDetail() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [showProfile, setShowProfile] = useState(false)
|
||||
const [fixResult, setFixResult] = useState<PlaylistFixResponse | null>(null)
|
||||
const [fixLoading, setFixLoading] = useState(false)
|
||||
const [fixError, setFixError] = useState<string | null>(null)
|
||||
const [dismissedOutliers, setDismissedOutliers] = useState<Set<number>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
@@ -44,6 +48,26 @@ export default function PlaylistDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleFix = async () => {
|
||||
if (!id) return
|
||||
setFixLoading(true)
|
||||
setFixError(null)
|
||||
setFixResult(null)
|
||||
setDismissedOutliers(new Set())
|
||||
try {
|
||||
const result = await fixPlaylist(id)
|
||||
setFixResult(result)
|
||||
} catch {
|
||||
setFixError('Failed to analyze playlist. Please try again.')
|
||||
} finally {
|
||||
setFixLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const dismissOutlier = (trackNumber: number) => {
|
||||
setDismissedOutliers((prev) => new Set([...prev, trackNumber]))
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
@@ -94,6 +118,18 @@ export default function PlaylistDetail() {
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Get Recommendations
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleFix}
|
||||
disabled={fixLoading}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-amber-50 text-amber-700 text-sm font-medium rounded-xl hover:bg-amber-100 transition-colors cursor-pointer border-none disabled:opacity-50"
|
||||
>
|
||||
{fixLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Wand2 className="w-4 h-4" />
|
||||
)}
|
||||
{fixLoading ? 'Analyzing...' : 'Fix My Playlist'}
|
||||
</button>
|
||||
{playlist.taste_profile && (
|
||||
<button
|
||||
onClick={() => setShowProfile(!showProfile)}
|
||||
@@ -123,6 +159,121 @@ export default function PlaylistDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fix My Playlist Loading */}
|
||||
{fixLoading && (
|
||||
<div className="bg-white rounded-2xl border border-amber-200 p-8 text-center">
|
||||
<Loader2 className="w-8 h-8 text-amber-500 animate-spin mx-auto mb-3" />
|
||||
<p className="text-charcoal font-medium">Analyzing your playlist...</p>
|
||||
<p className="text-sm text-charcoal-muted mt-1">Looking for tracks that might not quite fit the vibe</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fix My Playlist Error */}
|
||||
{fixError && (
|
||||
<div className="bg-white rounded-2xl border border-red-200 p-6">
|
||||
<p className="text-red-600 text-sm">{fixError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fix My Playlist Results */}
|
||||
{fixResult && (
|
||||
<div className="space-y-5">
|
||||
{/* Playlist Vibe */}
|
||||
<div className="bg-white rounded-2xl border border-purple-100 p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-charcoal flex items-center gap-2">
|
||||
<Wand2 className="w-5 h-5 text-purple" />
|
||||
Playlist Vibe
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setFixResult(null)}
|
||||
className="p-1.5 text-charcoal-muted hover:text-charcoal rounded-lg hover:bg-purple-50 transition-colors cursor-pointer bg-transparent border-none"
|
||||
title="Dismiss results"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-charcoal-muted leading-relaxed">{fixResult.playlist_vibe}</p>
|
||||
</div>
|
||||
|
||||
{/* Outlier Tracks */}
|
||||
{fixResult.outliers.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-purple-100 p-6">
|
||||
<h2 className="text-lg font-semibold text-charcoal flex items-center gap-2 mb-1">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
||||
Tracks Worth Reconsidering
|
||||
</h2>
|
||||
<p className="text-sm text-charcoal-muted mb-4">These tracks might not quite match the rest of your playlist's energy</p>
|
||||
<div className="space-y-3">
|
||||
{fixResult.outliers.map((outlier, i) => (
|
||||
!dismissedOutliers.has(outlier.track_number) && (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-start gap-4 p-4 rounded-xl bg-amber-50/60 border border-amber-100"
|
||||
>
|
||||
<span className="w-8 h-8 rounded-lg bg-amber-100 text-amber-700 flex items-center justify-center text-sm font-medium flex-shrink-0">
|
||||
#{outlier.track_number}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-charcoal">
|
||||
{outlier.artist} — {outlier.title}
|
||||
</p>
|
||||
<p className="text-sm text-charcoal-muted mt-1 leading-relaxed">
|
||||
{outlier.reason}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => dismissOutlier(outlier.track_number)}
|
||||
className="p-1.5 text-charcoal-muted/50 hover:text-charcoal-muted rounded-lg hover:bg-amber-100 transition-colors cursor-pointer bg-transparent border-none flex-shrink-0"
|
||||
title="Dismiss suggestion"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggested Replacements */}
|
||||
{fixResult.replacements.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-purple-100 p-6">
|
||||
<h2 className="text-lg font-semibold text-charcoal flex items-center gap-2 mb-1">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
|
||||
Suggested Replacements
|
||||
</h2>
|
||||
<p className="text-sm text-charcoal-muted mb-4">These tracks would fit your playlist's vibe perfectly</p>
|
||||
<div className="space-y-3">
|
||||
{fixResult.replacements.map((replacement, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-start gap-4 p-4 rounded-xl bg-emerald-50/60 border border-emerald-100"
|
||||
>
|
||||
<span className="w-8 h-8 rounded-lg bg-emerald-100 text-emerald-700 flex items-center justify-center text-sm font-medium flex-shrink-0">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-charcoal">
|
||||
{replacement.artist} — {replacement.title}
|
||||
</p>
|
||||
{replacement.album && (
|
||||
<p className="text-xs text-charcoal-muted mt-0.5">
|
||||
from <span className="italic">{replacement.album}</span>
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-charcoal-muted mt-1 leading-relaxed">
|
||||
{replacement.reason}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Track List */}
|
||||
<div className="bg-white rounded-2xl border border-purple-100 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-purple-50">
|
||||
@@ -132,7 +283,11 @@ export default function PlaylistDetail() {
|
||||
{playlist.tracks.map((track, index) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className="flex items-center gap-4 px-6 py-3 hover:bg-purple-50/50 transition-colors"
|
||||
className={`flex items-center gap-4 px-6 py-3 hover:bg-purple-50/50 transition-colors ${
|
||||
fixResult?.outliers.some((o) => o.track_number === index + 1) && !dismissedOutliers.has(index + 1)
|
||||
? 'bg-amber-50/30 border-l-2 border-l-amber-300'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<span className="w-8 text-sm text-charcoal-muted/50 text-right flex-shrink-0">
|
||||
{index + 1}
|
||||
|
||||
Reference in New Issue
Block a user