diff --git a/backend/app/services/recommender.py b/backend/app/services/recommender.py index fe02db9..ee4d0f2 100644 --- a/backend/app/services/recommender.py +++ b/backend/app/services/recommender.py @@ -186,7 +186,7 @@ Already in their library (do NOT recommend these): {disliked_instruction} {exclude_instruction} -Respond with exactly {count} music recommendations as a JSON array. Each item should have: +Respond with exactly {count * 3} music recommendations as a JSON array. Give more than requested so we can verify they exist. Each item should have: - "title": song title - "artist": artist name - "album": album name (if known) @@ -216,7 +216,36 @@ Return ONLY the JSON array, no other text.""" except json.JSONDecodeError: return [], remaining - # Save to DB with YouTube links + # Verify each recommendation exists on YouTube Music before saving + import asyncio + from app.services.youtube_music import search_track + from difflib import SequenceMatcher + + def _normalize(s: str) -> str: + import re + return re.sub(r'[^a-z0-9\s]', '', s.lower()).strip() + + def _sim(a: str, b: str) -> float: + return SequenceMatcher(None, _normalize(a), _normalize(b)).ratio() + + def verify_track(artist: str, title: str) -> dict | None: + """Search YouTube Music to verify a track exists and get the real data.""" + try: + results = search_track(f"{artist} {title}") + for r in results[:3]: + r_artist = r.get("artist", "") + r_title = r.get("title", "") + # Check artist similarity (>0.5) and title similarity (>0.4) + if _sim(r_artist, artist) >= 0.5 and _sim(r_title, title) >= 0.4: + return r + # Or very strong artist match with any title + if _sim(r_artist, artist) >= 0.8: + return r + except Exception: + pass + return None + + # Save to DB — only keep verified tracks recommendations = [] for rec in recs_data: if len(recommendations) >= count: @@ -224,17 +253,34 @@ Return ONLY the JSON array, no other text.""" artist = rec.get("artist", "Unknown") title = rec.get("title", "Unknown") + reason = rec.get("reason", "") - # YouTube search link for every recommendation - youtube_url = f"https://www.youtube.com/results?search_query={quote_plus(f'{artist} {title} official music video')}" + # Verify on YouTube Music (run sync in thread) + verified = await asyncio.to_thread(verify_track, artist, title) + + if not verified: + continue # Skip hallucinated songs + + # Use verified data (correct artist/title from YouTube Music) + real_artist = verified.get("artist", artist) + real_title = verified.get("title", title) + youtube_id = verified.get("youtube_id") + image_url = verified.get("image_url") + + # Direct YouTube link if we have a video ID, otherwise search + if youtube_id: + youtube_url = f"https://music.youtube.com/watch?v={youtube_id}" + else: + youtube_url = f"https://www.youtube.com/results?search_query={quote_plus(f'{real_artist} {real_title} official music video')}" r = Recommendation( user_id=user.id, playlist_id=playlist_id, - title=title, - artist=artist, + title=real_title, + artist=real_artist, album=rec.get("album"), - reason=rec.get("reason", ""), + image_url=image_url, + reason=reason, score=rec.get("score"), query=query, youtube_url=youtube_url, diff --git a/frontend/src/pages/BandcampDiscover.tsx b/frontend/src/pages/BandcampDiscover.tsx index cd50b6e..72d23ec 100644 --- a/frontend/src/pages/BandcampDiscover.tsx +++ b/frontend/src/pages/BandcampDiscover.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { Disc3, Music, ExternalLink, Loader2 } from 'lucide-react' -import { discoverBandcamp, getBandcampTags, BandcampRelease } from '../lib/api' +import { discoverBandcamp, getBandcampTags, type BandcampRelease } from '../lib/api' const SORT_OPTIONS = [ { value: 'new', label: 'New Releases' },