diff --git a/backend/app/api/endpoints/bandcamp.py b/backend/app/api/endpoints/bandcamp.py new file mode 100644 index 0000000..1635031 --- /dev/null +++ b/backend/app/api/endpoints/bandcamp.py @@ -0,0 +1,58 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel + +from app.core.security import get_current_user +from app.models.user import User +from app.services.bandcamp import search_bandcamp, get_embed_data + +router = APIRouter(prefix="/bandcamp", tags=["bandcamp"]) + + +class BandcampResult(BaseModel): + title: str + artist: str + art_url: str | None = None + bandcamp_url: str + item_type: str + + +class BandcampEmbedResponse(BaseModel): + embed_url: str + title: str + artist: str + art_url: str | None = None + + +@router.get("/search", response_model=list[BandcampResult]) +async def bandcamp_search( + q: str = Query(..., min_length=1), + type: str = Query("t", pattern="^[tab]$"), + user: User = Depends(get_current_user), +): + """Search Bandcamp for tracks, albums, or artists.""" + results = await search_bandcamp(q.strip(), item_type=type) + return [BandcampResult(**r) for r in results] + + +@router.get("/embed", response_model=BandcampEmbedResponse) +async def bandcamp_embed( + url: str = Query(..., min_length=1), + user: User = Depends(get_current_user), +): + """Get embed data for a Bandcamp URL.""" + if "bandcamp.com" not in url: + raise HTTPException(status_code=400, detail="Not a valid Bandcamp URL") + + data = await get_embed_data(url.strip()) + if not data: + raise HTTPException( + status_code=404, + detail="Could not extract embed data from this Bandcamp page", + ) + + return BandcampEmbedResponse( + embed_url=data["embed_url"], + title=data["title"], + artist=data["artist"], + art_url=data.get("art_url"), + ) diff --git a/backend/app/main.py b/backend/app/main.py index 549648d..f5811fb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.core.config import settings -from app.api.endpoints import auth, billing, lastfm, manual_import, playlists, recommendations, youtube_music +from app.api.endpoints import auth, bandcamp, billing, lastfm, manual_import, playlists, recommendations, youtube_music app = FastAPI(title="Vynl API", version="1.0.0") @@ -21,6 +21,7 @@ app.include_router(recommendations.router, prefix="/api") app.include_router(youtube_music.router, prefix="/api") app.include_router(manual_import.router, prefix="/api") app.include_router(lastfm.router, prefix="/api") +app.include_router(bandcamp.router, prefix="/api") @app.get("/api/health") diff --git a/backend/app/services/bandcamp.py b/backend/app/services/bandcamp.py new file mode 100644 index 0000000..d150082 --- /dev/null +++ b/backend/app/services/bandcamp.py @@ -0,0 +1,218 @@ +import re + +import httpx + + +AUTOCOMPLETE_URL = "https://bandcamp.com/api/fuzzysearch/2/autocomplete" +SEARCH_URL = "https://bandcamp.com/search" + +HEADERS = { + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0", +} + + +async def search_bandcamp(query: str, item_type: str = "t") -> list[dict]: + """Search Bandcamp for tracks, albums, or artists. + + item_type: 't' for tracks, 'a' for albums, 'b' for bands/artists. + """ + # Try autocomplete API first + results = await _search_autocomplete(query, item_type) + if results: + return results + + # Fall back to HTML scraping + return await _search_html(query, item_type) + + +async def _search_autocomplete(query: str, item_type: str) -> list[dict]: + """Try the undocumented Bandcamp autocomplete API.""" + try: + async with httpx.AsyncClient(timeout=10, headers=HEADERS) as client: + resp = await client.get(AUTOCOMPLETE_URL, params={"q": query}) + + if resp.status_code != 200: + return [] + + data = resp.json() + results = [] + + # The autocomplete API returns results grouped by type + auto_results = data.get("results", []) + for item in auto_results: + result_type = item.get("type", "") + + # Map autocomplete types to our item_type filter + if item_type == "t" and result_type != "t": + continue + if item_type == "a" and result_type != "a": + continue + if item_type == "b" and result_type != "b": + continue + + results.append({ + "title": item.get("name", ""), + "artist": item.get("band_name", ""), + "art_url": item.get("img", item.get("art_id", None)), + "bandcamp_url": item.get("url", ""), + "item_type": result_type, + }) + + return results[:20] + except Exception: + return [] + + +async def _search_html(query: str, item_type: str) -> list[dict]: + """Fall back to scraping Bandcamp search results HTML.""" + params = {"q": query, "item_type": item_type} + try: + async with httpx.AsyncClient(timeout=15, headers=HEADERS, follow_redirects=True) as client: + resp = await client.get(SEARCH_URL, params=params) + + if resp.status_code != 200: + return [] + + html = resp.text + results = [] + + # Split by search result items + items = re.split(r'\s*]*>\s*([^<]+)', + item_html, + ) + if not heading_match: + continue + + url = heading_match.group(1).strip() + title = heading_match.group(2).strip() + + # Extract artist/subhead info + subhead_match = re.search( + r'class="subhead">\s*([^<]+)', item_html + ) + artist = "" + if subhead_match: + subhead = subhead_match.group(1).strip() + # Subhead format varies: "by Artist" or "from Album by Artist" + by_match = re.search(r'by\s+(.+)', subhead) + if by_match: + artist = by_match.group(1).strip() + else: + artist = subhead + + # Extract album art URL + art_match = re.search( + r'class="art">\s*= 20: + break + + return results + except Exception: + return [] + + +async def get_embed_data(bandcamp_url: str) -> dict | None: + """Get embed info for a Bandcamp URL. + + Fetches the page HTML, extracts the track/album ID, and returns + the embed iframe URL along with metadata. + """ + try: + async with httpx.AsyncClient(timeout=15, headers=HEADERS, follow_redirects=True) as client: + resp = await client.get(bandcamp_url) + + if resp.status_code != 200: + return None + + html = resp.text + + # Determine if this is a track or album URL + is_track = "/track/" in bandcamp_url + + # Try to extract the ID from meta tags or data attributes + # Look for: + # or data-tralbum-id="12345" + item_id = None + + tralbum_match = re.search(r'data-tralbum-id="(\d+)"', html) + if tralbum_match: + item_id = tralbum_match.group(1) + + if not item_id: + # Try og:video meta tag which contains embed URL with ID + og_match = re.search( + r' } /> + + + + + + } + /> )} + + + + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8dafbb6..4382122 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -211,4 +211,30 @@ export const createBillingPortal = () => export const getBillingStatus = () => api.get('/billing/status').then((r) => r.data) +// Bandcamp +export interface BandcampResult { + title: string + artist: string + art_url: string | null + bandcamp_url: string + item_type: string +} + +export interface BandcampEmbed { + embed_url: string + title: string + artist: string + art_url: string | null +} + +export async function searchBandcamp(query: string, type: string = 't'): Promise { + const { data } = await api.get('/bandcamp/search', { params: { q: query, type } }) + return data +} + +export async function getBandcampEmbed(url: string): Promise { + const { data } = await api.get('/bandcamp/embed', { params: { url } }) + return data +} + export default api diff --git a/frontend/src/pages/ListeningRoom.tsx b/frontend/src/pages/ListeningRoom.tsx new file mode 100644 index 0000000..ec8d964 --- /dev/null +++ b/frontend/src/pages/ListeningRoom.tsx @@ -0,0 +1,346 @@ +import { useState, useEffect } from 'react' +import { useSearchParams } from 'react-router-dom' +import { Search, Headphones, ExternalLink, Play, X, Music, Disc3 } from 'lucide-react' +import { searchBandcamp, getBandcampEmbed } from '../lib/api' +import type { BandcampResult, BandcampEmbed } from '../lib/api' + +type SearchType = 't' | 'a' | 'b' + +interface QueueItem { + result: BandcampResult + embed: BandcampEmbed | null + loading: boolean +} + +export default function ListeningRoom() { + const [searchParams] = useSearchParams() + const [query, setQuery] = useState(searchParams.get('q') || '') + const [searchType, setSearchType] = useState('t') + const [results, setResults] = useState([]) + const [searching, setSearching] = useState(false) + const [searchError, setSearchError] = useState('') + const [currentPlayer, setCurrentPlayer] = useState(null) + const [queue, setQueue] = useState([]) + + // Auto-search if query param is provided + useEffect(() => { + const q = searchParams.get('q') + if (q) { + setQuery(q) + handleSearch(q) + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + async function handleSearch(searchQuery?: string) { + const q = (searchQuery || query).trim() + if (!q) return + + setSearching(true) + setSearchError('') + try { + const data = await searchBandcamp(q, searchType) + setResults(data) + if (data.length === 0) { + setSearchError('No results found. Try a different search term.') + } + } catch { + setSearchError('Search failed. Please try again.') + setResults([]) + } finally { + setSearching(false) + } + } + + async function handleListen(result: BandcampResult) { + const item: QueueItem = { result, embed: null, loading: true } + + // Set as current player immediately + setCurrentPlayer(item) + + try { + const embed = await getBandcampEmbed(result.bandcamp_url) + const loaded: QueueItem = { result, embed, loading: false } + setCurrentPlayer(loaded) + } catch { + setCurrentPlayer({ result, embed: null, loading: false }) + } + } + + function addToQueue(result: BandcampResult) { + // Don't add duplicates + if (queue.some((q) => q.result.bandcamp_url === result.bandcamp_url)) return + setQueue((prev) => [...prev, { result, embed: null, loading: false }]) + } + + async function playFromQueue(index: number) { + const item = queue[index] + setQueue((prev) => prev.filter((_, i) => i !== index)) + + setCurrentPlayer({ ...item, loading: true }) + try { + const embed = await getBandcampEmbed(item.result.bandcamp_url) + setCurrentPlayer({ result: item.result, embed, loading: false }) + } catch { + setCurrentPlayer({ result: item.result, embed: null, loading: false }) + } + } + + function removeFromQueue(index: number) { + setQueue((prev) => prev.filter((_, i) => i !== index)) + } + + const isAlbum = currentPlayer?.result.item_type === 'a' + const embedHeight = isAlbum ? 470 : 120 + + const typeLabels: Record = { t: 'Tracks', a: 'Albums', b: 'Artists' } + + return ( +
+ {/* Header */} +
+
+ +
+
+

Listening Room

+

+ Discover and listen to music on Bandcamp +

+
+
+ + {/* Search Section */} +
+
{ + e.preventDefault() + handleSearch() + }} + className="flex gap-3" + > +
+ + setQuery(e.target.value)} + placeholder="Search Bandcamp..." + className="w-full pl-11 pr-4 py-3 rounded-xl border border-purple-100 focus:border-purple focus:ring-2 focus:ring-purple/20 outline-none text-charcoal placeholder:text-charcoal-muted/50" + /> +
+ +
+ + {/* Type Toggle */} +
+ {(Object.entries(typeLabels) as [SearchType, string][]).map(([type, label]) => ( + + ))} +
+
+ + {/* Search Results */} + {searchError && ( +
+ +

{searchError}

+
+ )} + + {results.length > 0 && ( +
+

+ Results ({results.length}) +

+
+ {results.map((result, i) => ( +
+
+ {/* Art */} +
+ {result.art_url ? ( + {result.title} + ) : ( + + )} +
+ + {/* Info */} +
+

+ {result.title} +

+

+ {result.artist} +

+ +
+ + + + + +
+
+
+
+ ))} +
+
+ )} + + {/* Player Section */} + {(currentPlayer || queue.length > 0) && ( +
+ {/* Now Playing */} + {currentPlayer && ( +
+
+ +

Now Playing

+
+ + {currentPlayer.loading ? ( +
+
+
+ ) : currentPlayer.embed ? ( +
+
+ {currentPlayer.embed.art_url && ( + {currentPlayer.embed.title} + )} +
+

+ {currentPlayer.embed.title} +

+

+ {currentPlayer.embed.artist} +

+
+ + + Bandcamp + +
+ +