From 37fccc6eefad7fc7409a42fdb5c890ed7ed635bb Mon Sep 17 00:00:00 2001 From: root Date: Mon, 30 Mar 2026 23:42:03 -0500 Subject: [PATCH] Wire Bandcamp into AI recommendations - prioritize indie artists, attach Bandcamp links to results --- .../alembic/versions/002_add_bandcamp_url.py | 24 +++++++++++++++++++ backend/app/models/recommendation.py | 1 + backend/app/schemas/recommendation.py | 1 + backend/app/services/recommender.py | 16 ++++++++++++- backend/requirements.txt | 1 + frontend/Dockerfile | 2 +- .../src/components/RecommendationCard.tsx | 24 +++++++++---------- frontend/src/lib/api.ts | 1 + frontend/src/pages/Playlists.tsx | 12 +++++----- 9 files changed, 62 insertions(+), 20 deletions(-) create mode 100644 backend/alembic/versions/002_add_bandcamp_url.py diff --git a/backend/alembic/versions/002_add_bandcamp_url.py b/backend/alembic/versions/002_add_bandcamp_url.py new file mode 100644 index 0000000..24e8d61 --- /dev/null +++ b/backend/alembic/versions/002_add_bandcamp_url.py @@ -0,0 +1,24 @@ +"""Add bandcamp_url to recommendations + +Revision ID: 002 +Revises: 001 +Create Date: 2026-03-30 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "002" +down_revision: Union[str, None] = "001" +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("bandcamp_url", sa.String(500), nullable=True)) + + +def downgrade() -> None: + op.drop_column("recommendations", "bandcamp_url") diff --git a/backend/app/models/recommendation.py b/backend/app/models/recommendation.py index 7414acd..43f999c 100644 --- a/backend/app/models/recommendation.py +++ b/backend/app/models/recommendation.py @@ -20,6 +20,7 @@ class Recommendation(Base): spotify_id: Mapped[str | None] = mapped_column(String(255), nullable=True) 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) # AI explanation reason: Mapped[str] = mapped_column(Text) diff --git a/backend/app/schemas/recommendation.py b/backend/app/schemas/recommendation.py index c9d5b02..9bb7670 100644 --- a/backend/app/schemas/recommendation.py +++ b/backend/app/schemas/recommendation.py @@ -16,6 +16,7 @@ class RecommendationItem(BaseModel): spotify_id: str | None = None preview_url: str | None = None image_url: str | None = None + bandcamp_url: str | None = None reason: str score: float | None = None saved: bool = False diff --git a/backend/app/services/recommender.py b/backend/app/services/recommender.py index 02bebd8..0aefb7b 100644 --- a/backend/app/services/recommender.py +++ b/backend/app/services/recommender.py @@ -129,7 +129,7 @@ Respond with exactly 5 music recommendations as a JSON array. Each item should h - "reason": A warm, personal 2-3 sentence explanation of WHY they'll love this track. Reference specific qualities from their taste profile. Be specific about sonic qualities, not generic. - "score": confidence score 0.0-1.0 -Focus on discovery - prioritize lesser-known artists, deep cuts, and hidden gems over obvious popular choices. +IMPORTANT: Strongly prioritize independent and underground artists who release music on Bandcamp. Think DIY, indie labels, self-released artists, and the kind of music you'd find crate-digging on Bandcamp. Mix in some Bandcamp-type artists alongside any well-known recommendations. Focus on real discovery — lesser-known artists, deep cuts, and hidden gems over obvious popular choices. Return ONLY the JSON array, no other text.""" # Call Claude API @@ -152,9 +152,22 @@ Return ONLY the JSON array, no other text.""" except json.JSONDecodeError: return [], remaining + # Search Bandcamp for each recommendation to attach real links + from app.services.bandcamp import search_bandcamp + # Save to DB recommendations = [] for rec in recs_data[:5]: + bandcamp_url = None + try: + results = await search_bandcamp( + f"{rec.get('artist', '')} {rec.get('title', '')}", item_type="t" + ) + if results: + bandcamp_url = results[0].get("bandcamp_url") + except Exception: + pass + r = Recommendation( user_id=user.id, playlist_id=playlist_id, @@ -164,6 +177,7 @@ Return ONLY the JSON array, no other text.""" reason=rec.get("reason", ""), score=rec.get("score"), query=query, + bandcamp_url=bandcamp_url, ) db.add(r) recommendations.append(r) diff --git a/backend/requirements.txt b/backend/requirements.txt index 74fd460..8ecda20 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,6 +6,7 @@ asyncpg==0.30.0 psycopg2-binary==2.9.10 python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 python-multipart==0.0.20 pydantic[email]==2.10.4 pydantic-settings==2.7.1 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index f4ecb48..0d97300 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -9,4 +9,4 @@ COPY . . EXPOSE 5173 -CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] +CMD ["npx", "vite", "--host", "0.0.0.0"] diff --git a/frontend/src/components/RecommendationCard.tsx b/frontend/src/components/RecommendationCard.tsx index 09d6d30..7d0ee82 100644 --- a/frontend/src/components/RecommendationCard.tsx +++ b/frontend/src/components/RecommendationCard.tsx @@ -61,25 +61,25 @@ export default function RecommendationCard({ recommendation, onToggleSave, savin /> - {recommendation.spotify_url && ( + {recommendation.bandcamp_url ? ( + ) : ( + + + )} - - - - diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4382122..eea6d55 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -96,6 +96,7 @@ export interface RecommendationItem { album: string image_url: string | null spotify_url: string | null + bandcamp_url: string | null reason: string saved: boolean created_at: string diff --git a/frontend/src/pages/Playlists.tsx b/frontend/src/pages/Playlists.tsx index e14743c..c1cdd5d 100644 --- a/frontend/src/pages/Playlists.tsx +++ b/frontend/src/pages/Playlists.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { Link } from 'react-router-dom' -import { ListMusic, Plus, Loader2, Music, ChevronRight, Download, X, Youtube, Link2, ClipboardPaste } from 'lucide-react' +import { ListMusic, Plus, Loader2, Music, ChevronRight, Download, X, Play, Link2, ClipboardPaste } from 'lucide-react' import { getPlaylists, getSpotifyPlaylists, importSpotifyPlaylist, importYouTubePlaylist, previewLastfm, importLastfm, importPastedSongs, type PlaylistResponse, type SpotifyPlaylistItem, type LastfmPreviewResponse } from '../lib/api' export default function Playlists() { @@ -8,7 +8,7 @@ export default function Playlists() { const [spotifyPlaylists, setSpotifyPlaylists] = useState([]) const [showImport, setShowImport] = useState(false) const [showYouTubeImport, setShowYouTubeImport] = useState(false) - const [youtubeUrl, setYoutubeUrl] = useState('') + const [youtubeUrl, setPlayUrl] = useState('') const [importingYouTube, setImportingYouTube] = useState(false) const [importing, setImporting] = useState(null) const [loadingSpotify, setLoadingSpotify] = useState(false) @@ -60,7 +60,7 @@ export default function Playlists() { try { const imported = await importYouTubePlaylist(youtubeUrl.trim()) setPlaylists((prev) => [...prev, imported]) - setYoutubeUrl('') + setPlayUrl('') setShowYouTubeImport(false) } catch (err: any) { setError(err.response?.data?.detail || 'Failed to import YouTube Music playlist') @@ -162,7 +162,7 @@ export default function Playlists() { onClick={() => setShowYouTubeImport(true)} className="flex items-center gap-2 px-5 py-2.5 bg-red-600 text-white font-medium rounded-xl hover:bg-red-700 transition-colors cursor-pointer border-none text-sm" > - + Import from YouTube Music