Add Bandcamp/YouTube links on all recs, Fix My Playlist, configurable rec count

- Bandcamp: match artist+song, fall back to artist-only page
- YouTube fallback: if not on Bandcamp, link to YouTube music video search
- Fix My Playlist: AI analyzes playlist, finds outliers, suggests replacements
- User chooses recommendation count (5, 10, 15, 20)
This commit is contained in:
root
2026-03-31 08:45:02 -05:00
parent 47ab3dd847
commit 240032d972
8 changed files with 93 additions and 21 deletions

View File

@@ -0,0 +1,24 @@
"""Add youtube_url to recommendations
Revision ID: 004
Revises: 003
Create Date: 2026-03-30
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "004"
down_revision: Union[str, None] = "003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("recommendations", sa.Column("youtube_url", sa.String(500), nullable=True))
def downgrade() -> None:
op.drop_column("recommendations", "youtube_url")

View File

@@ -23,7 +23,7 @@ async def generate(
recs, remaining = await generate_recommendations( recs, remaining = await generate_recommendations(
db, user, playlist_id=data.playlist_id, query=data.query, bandcamp_mode=data.bandcamp_mode, db, user, playlist_id=data.playlist_id, query=data.query, bandcamp_mode=data.bandcamp_mode,
mode=data.mode, adventurousness=data.adventurousness, exclude=data.exclude, mode=data.mode, adventurousness=data.adventurousness, exclude=data.exclude, count=data.count,
) )
if not recs and remaining == 0: if not recs and remaining == 0:

View File

@@ -21,6 +21,7 @@ class Recommendation(Base):
preview_url: Mapped[str | None] = mapped_column(String(500), nullable=True) preview_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
image_url: Mapped[str | None] = mapped_column(String(500), nullable=True) image_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
bandcamp_url: Mapped[str | None] = mapped_column(String(500), nullable=True) bandcamp_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
youtube_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
# AI explanation # AI explanation
reason: Mapped[str] = mapped_column(Text) reason: Mapped[str] = mapped_column(Text)

View File

@@ -10,6 +10,7 @@ class RecommendationRequest(BaseModel):
mode: str = "discover" # discover, sonic_twin, era_bridge, deep_cuts, rising mode: str = "discover" # discover, sonic_twin, era_bridge, deep_cuts, rising
adventurousness: int = 3 # 1-5 adventurousness: int = 3 # 1-5
exclude: str | None = None # comma-separated genres to exclude exclude: str | None = None # comma-separated genres to exclude
count: int = 5 # Number of recommendations (5, 10, 15, 20)
class RecommendationItem(BaseModel): class RecommendationItem(BaseModel):
@@ -21,6 +22,7 @@ class RecommendationItem(BaseModel):
preview_url: str | None = None preview_url: str | None = None
image_url: str | None = None image_url: str | None = None
bandcamp_url: str | None = None bandcamp_url: str | None = None
youtube_url: str | None = None
reason: str reason: str
score: float | None = None score: float | None = None
saved: bool = False saved: bool = False

View File

@@ -26,6 +26,7 @@ async def search_bandcamp_verified(artist: str, title: str) -> dict | None:
"""Search Bandcamp and only return a result if the artist actually matches. """Search Bandcamp and only return a result if the artist actually matches.
Returns the best matching result or None if no good match found. Returns the best matching result or None if no good match found.
First tries artist+song, then falls back to artist-only search.
""" """
# Try track search first: "artist title" # Try track search first: "artist title"
results = await search_bandcamp(f"{artist} {title}", item_type="t") results = await search_bandcamp(f"{artist} {title}", item_type="t")
@@ -36,12 +37,12 @@ async def search_bandcamp_verified(artist: str, title: str) -> dict | None:
if artist_sim >= 0.75 and title_sim >= 0.5: if artist_sim >= 0.75 and title_sim >= 0.5:
return r return r
# Try artist/band search as fallback — very strict matching # Try artist/band search as fallback — return their artist page URL
results = await search_bandcamp(artist, item_type="b") results = await search_bandcamp(artist, item_type="b")
for r in results: for r in results:
# For band results, title IS the band name # For band results, title IS the band name
name = r.get("title", "") or r.get("artist", "") name = r.get("title", "") or r.get("artist", "")
if _similarity(name, artist) >= 0.8: if _similarity(name, artist) >= 0.7:
return r return r
return None return None

View File

@@ -1,5 +1,6 @@
import json import json
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from urllib.parse import quote_plus
import anthropic import anthropic
from sqlalchemy import select, func from sqlalchemy import select, func
@@ -90,6 +91,7 @@ async def generate_recommendations(
mode: str = "discover", mode: str = "discover",
adventurousness: int = 3, adventurousness: int = 3,
exclude: str | None = None, exclude: str | None = None,
count: int = 5,
) -> tuple[list[Recommendation], int | None]: ) -> tuple[list[Recommendation], int | None]:
"""Generate AI music recommendations using Claude.""" """Generate AI music recommendations using Claude."""
@@ -184,7 +186,7 @@ Already in their library (do NOT recommend these):
{disliked_instruction} {disliked_instruction}
{exclude_instruction} {exclude_instruction}
Respond with exactly {"15" if bandcamp_mode else "5"} music recommendations as a JSON array. Each item should have: Respond with exactly {count * 3 if bandcamp_mode else count} music recommendations as a JSON array. Each item should have:
- "title": song title - "title": song title
- "artist": artist name - "artist": artist name
- "album": album name (if known) - "album": album name (if known)
@@ -219,33 +221,41 @@ Return ONLY the JSON array, no other text."""
# Save to DB — in bandcamp mode, only keep results verified on Bandcamp # Save to DB — in bandcamp mode, only keep results verified on Bandcamp
recommendations = [] recommendations = []
for rec in recs_data: for rec in recs_data:
if len(recommendations) >= 5: if len(recommendations) >= count:
break break
artist = rec.get("artist", "Unknown")
title = rec.get("title", "Unknown")
bandcamp_url = None bandcamp_url = None
if bandcamp_mode: youtube_url = None
# Try Bandcamp for every recommendation
try: try:
match = await search_bandcamp_verified( match = await search_bandcamp_verified(artist, title)
rec.get("artist", ""), rec.get("title", "")
)
if match: if match:
bandcamp_url = match.get("bandcamp_url") bandcamp_url = match.get("bandcamp_url")
else:
# Not verified on Bandcamp — skip
continue
except Exception: except Exception:
pass
if bandcamp_mode and not bandcamp_url:
# In bandcamp mode, skip recommendations not found on Bandcamp
continue continue
# If not on Bandcamp, build a YouTube search link
if not bandcamp_url:
youtube_url = f"https://www.youtube.com/results?search_query={quote_plus(f'{artist} {title} official music video')}"
r = Recommendation( r = Recommendation(
user_id=user.id, user_id=user.id,
playlist_id=playlist_id, playlist_id=playlist_id,
title=rec.get("title", "Unknown"), title=title,
artist=rec.get("artist", "Unknown"), artist=artist,
album=rec.get("album"), album=rec.get("album"),
reason=rec.get("reason", ""), reason=rec.get("reason", ""),
score=rec.get("score"), score=rec.get("score"),
query=query, query=query,
bandcamp_url=bandcamp_url, bandcamp_url=bandcamp_url,
youtube_url=youtube_url,
) )
db.add(r) db.add(r)
recommendations.append(r) recommendations.append(r)

View File

@@ -80,7 +80,7 @@ export default function RecommendationCard({ recommendation, onToggleSave, onDis
</button> </button>
)} )}
{recommendation.bandcamp_url && ( {recommendation.bandcamp_url ? (
<a <a
href={recommendation.bandcamp_url} href={recommendation.bandcamp_url}
target="_blank" target="_blank"
@@ -90,7 +90,17 @@ export default function RecommendationCard({ recommendation, onToggleSave, onDis
> >
<ExternalLink className="w-4 h-4" /> <ExternalLink className="w-4 h-4" />
</a> </a>
)} ) : recommendation.youtube_url ? (
<a
href={recommendation.youtube_url}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-full bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
title="Watch on YouTube"
>
<ExternalLink className="w-4 h-4" />
</a>
) : null}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -38,6 +38,7 @@ export default function Discover() {
const [mode, setMode] = useState('discover') const [mode, setMode] = useState('discover')
const [adventurousness, setAdventurousness] = useState(3) const [adventurousness, setAdventurousness] = useState(3)
const [excludeGenres, setExcludeGenres] = useState('') const [excludeGenres, setExcludeGenres] = useState('')
const [count, setCount] = useState(5)
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
@@ -71,6 +72,7 @@ export default function Discover() {
mode, mode,
adventurousness, adventurousness,
excludeGenres.trim() || undefined, excludeGenres.trim() || undefined,
count,
) )
setResults(response.recommendations) setResults(response.recommendations)
setRemaining(response.remaining_this_week) setRemaining(response.remaining_this_week)
@@ -221,6 +223,28 @@ export default function Discover() {
</div> </div>
</div> </div>
{/* Recommendation Count */}
<div>
<label className="block text-sm font-medium text-charcoal mb-3">
How many recommendations
</label>
<div className="flex gap-2">
{[5, 10, 15, 20].map((n) => (
<button
key={n}
onClick={() => setCount(n)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-all cursor-pointer border ${
count === n
? 'bg-purple text-white border-purple shadow-md shadow-purple/20'
: 'bg-white text-charcoal-muted border-purple-100 hover:border-purple/30 hover:text-charcoal'
}`}
>
{n}
</button>
))}
</div>
</div>
{/* Block Genres */} {/* Block Genres */}
<div> <div>
<label className="block text-sm font-medium text-charcoal mb-2"> <label className="block text-sm font-medium text-charcoal mb-2">