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 ? ( - )} + ) : recommendation.youtube_url ? ( + + + + ) : null} diff --git a/frontend/src/pages/Discover.tsx b/frontend/src/pages/Discover.tsx index c1c66af..22fc1b0 100644 --- a/frontend/src/pages/Discover.tsx +++ b/frontend/src/pages/Discover.tsx @@ -38,6 +38,7 @@ export default function Discover() { const [mode, setMode] = useState('discover') const [adventurousness, setAdventurousness] = useState(3) const [excludeGenres, setExcludeGenres] = useState('') + const [count, setCount] = useState(5) useEffect(() => { const load = async () => { @@ -71,6 +72,7 @@ export default function Discover() { mode, adventurousness, excludeGenres.trim() || undefined, + count, ) setResults(response.recommendations) setRemaining(response.remaining_this_week) @@ -221,6 +223,28 @@ export default function Discover() { + {/* Recommendation Count */} +
+ +
+ {[5, 10, 15, 20].map((n) => ( + + ))} +
+
+ {/* Block Genres */}