diff --git a/backend/alembic/versions/004_add_youtube_url.py b/backend/alembic/versions/004_add_youtube_url.py
new file mode 100644
index 0000000..0d2f0b3
--- /dev/null
+++ b/backend/alembic/versions/004_add_youtube_url.py
@@ -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")
diff --git a/backend/app/api/endpoints/recommendations.py b/backend/app/api/endpoints/recommendations.py
index 6a30c88..99aa9b3 100644
--- a/backend/app/api/endpoints/recommendations.py
+++ b/backend/app/api/endpoints/recommendations.py
@@ -23,7 +23,7 @@ async def generate(
recs, remaining = await generate_recommendations(
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:
diff --git a/backend/app/models/recommendation.py b/backend/app/models/recommendation.py
index 030a2fa..06b64c9 100644
--- a/backend/app/models/recommendation.py
+++ b/backend/app/models/recommendation.py
@@ -21,6 +21,7 @@ class Recommendation(Base):
preview_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)
+ youtube_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
# AI explanation
reason: Mapped[str] = mapped_column(Text)
diff --git a/backend/app/schemas/recommendation.py b/backend/app/schemas/recommendation.py
index b223722..1ec41fc 100644
--- a/backend/app/schemas/recommendation.py
+++ b/backend/app/schemas/recommendation.py
@@ -10,6 +10,7 @@ class RecommendationRequest(BaseModel):
mode: str = "discover" # discover, sonic_twin, era_bridge, deep_cuts, rising
adventurousness: int = 3 # 1-5
exclude: str | None = None # comma-separated genres to exclude
+ count: int = 5 # Number of recommendations (5, 10, 15, 20)
class RecommendationItem(BaseModel):
@@ -21,6 +22,7 @@ class RecommendationItem(BaseModel):
preview_url: str | None = None
image_url: str | None = None
bandcamp_url: str | None = None
+ youtube_url: str | None = None
reason: str
score: float | None = None
saved: bool = False
diff --git a/backend/app/services/bandcamp.py b/backend/app/services/bandcamp.py
index 7695c37..efc4183 100644
--- a/backend/app/services/bandcamp.py
+++ b/backend/app/services/bandcamp.py
@@ -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.
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"
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:
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")
for r in results:
# For band results, title IS the band name
name = r.get("title", "") or r.get("artist", "")
- if _similarity(name, artist) >= 0.8:
+ if _similarity(name, artist) >= 0.7:
return r
return None
diff --git a/backend/app/services/recommender.py b/backend/app/services/recommender.py
index 0d6511d..dd3b314 100644
--- a/backend/app/services/recommender.py
+++ b/backend/app/services/recommender.py
@@ -1,5 +1,6 @@
import json
from datetime import datetime, timezone, timedelta
+from urllib.parse import quote_plus
import anthropic
from sqlalchemy import select, func
@@ -90,6 +91,7 @@ async def generate_recommendations(
mode: str = "discover",
adventurousness: int = 3,
exclude: str | None = None,
+ count: int = 5,
) -> tuple[list[Recommendation], int | None]:
"""Generate AI music recommendations using Claude."""
@@ -184,7 +186,7 @@ Already in their library (do NOT recommend these):
{disliked_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
- "artist": artist name
- "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
recommendations = []
for rec in recs_data:
- if len(recommendations) >= 5:
+ if len(recommendations) >= count:
break
+ artist = rec.get("artist", "Unknown")
+ title = rec.get("title", "Unknown")
bandcamp_url = None
- if bandcamp_mode:
- try:
- match = await search_bandcamp_verified(
- rec.get("artist", ""), rec.get("title", "")
- )
- if match:
- bandcamp_url = match.get("bandcamp_url")
- else:
- # Not verified on Bandcamp — skip
- continue
- except Exception:
- continue
+ youtube_url = None
+
+ # Try Bandcamp for every recommendation
+ try:
+ match = await search_bandcamp_verified(artist, title)
+ if match:
+ bandcamp_url = match.get("bandcamp_url")
+ except Exception:
+ pass
+
+ if bandcamp_mode and not bandcamp_url:
+ # In bandcamp mode, skip recommendations not found on Bandcamp
+ 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(
user_id=user.id,
playlist_id=playlist_id,
- title=rec.get("title", "Unknown"),
- artist=rec.get("artist", "Unknown"),
+ title=title,
+ artist=artist,
album=rec.get("album"),
reason=rec.get("reason", ""),
score=rec.get("score"),
query=query,
bandcamp_url=bandcamp_url,
+ youtube_url=youtube_url,
)
db.add(r)
recommendations.append(r)
diff --git a/frontend/src/components/RecommendationCard.tsx b/frontend/src/components/RecommendationCard.tsx
index 36c03f8..2045b69 100644
--- a/frontend/src/components/RecommendationCard.tsx
+++ b/frontend/src/components/RecommendationCard.tsx
@@ -80,7 +80,7 @@ export default function RecommendationCard({ recommendation, onToggleSave, onDis
)}
- {recommendation.bandcamp_url && (
+ {recommendation.bandcamp_url ? (