diff --git a/backend/app/api/endpoints/playlist_fix.py b/backend/app/api/endpoints/playlist_fix.py new file mode 100644 index 0000000..1771adb --- /dev/null +++ b/backend/app/api/endpoints/playlist_fix.py @@ -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", [])], + ) diff --git a/backend/app/main.py b/backend/app/main.py index 46c12b9..5f1910e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1c73e57..56881b7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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('/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 { 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(`/playlists/${playlistId}/fix`).then((r) => r.data) + // Taste Profile export const getTasteProfile = () => api.get('/profile/taste').then((r) => r.data) diff --git a/frontend/src/pages/PlaylistDetail.tsx b/frontend/src/pages/PlaylistDetail.tsx index 3a8ad24..84ac7f9 100644 --- a/frontend/src/pages/PlaylistDetail.tsx +++ b/frontend/src/pages/PlaylistDetail.tsx @@ -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(null) + const [fixLoading, setFixLoading] = useState(false) + const [fixError, setFixError] = useState(null) + const [dismissedOutliers, setDismissedOutliers] = useState>(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 (
@@ -94,6 +118,18 @@ export default function PlaylistDetail() { Get Recommendations + {playlist.taste_profile && (
)} + {/* Fix My Playlist Loading */} + {fixLoading && ( +
+ +

Analyzing your playlist...

+

Looking for tracks that might not quite fit the vibe

+
+ )} + + {/* Fix My Playlist Error */} + {fixError && ( +
+

{fixError}

+
+ )} + + {/* Fix My Playlist Results */} + {fixResult && ( +
+ {/* Playlist Vibe */} +
+
+

+ + Playlist Vibe +

+ +
+

{fixResult.playlist_vibe}

+
+ + {/* Outlier Tracks */} + {fixResult.outliers.length > 0 && ( +
+

+ + Tracks Worth Reconsidering +

+

These tracks might not quite match the rest of your playlist's energy

+
+ {fixResult.outliers.map((outlier, i) => ( + !dismissedOutliers.has(outlier.track_number) && ( +
+ + #{outlier.track_number} + +
+

+ {outlier.artist} — {outlier.title} +

+

+ {outlier.reason} +

+
+ +
+ ) + ))} +
+
+ )} + + {/* Suggested Replacements */} + {fixResult.replacements.length > 0 && ( +
+

+ + Suggested Replacements +

+

These tracks would fit your playlist's vibe perfectly

+
+ {fixResult.replacements.map((replacement, i) => ( +
+ + {i + 1} + +
+

+ {replacement.artist} — {replacement.title} +

+ {replacement.album && ( +

+ from {replacement.album} +

+ )} +

+ {replacement.reason} +

+
+
+ ))} +
+
+ )} +
+ )} + {/* Track List */}
@@ -132,7 +283,11 @@ export default function PlaylistDetail() { {playlist.tracks.map((track, index) => (
o.track_number === index + 1) && !dismissedOutliers.has(index + 1) + ? 'bg-amber-50/30 border-l-2 border-l-amber-300' + : '' + }`} > {index + 1}