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 */}
+
+
+
+ {/* Type Toggle */}
+
+ {(Object.entries(typeLabels) as [SearchType, string][]).map(([type, label]) => (
+
+ ))}
+
+
+
+ {/* Search Results */}
+ {searchError && (
+
+ )}
+
+ {results.length > 0 && (
+
+
+ Results ({results.length})
+
+
+ {results.map((result, i) => (
+
+
+ {/* Art */}
+
+ {result.art_url ? (
+

+ ) : (
+
+ )}
+
+
+ {/* 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.artist}
+
+
+
+
+ Bandcamp
+
+
+
+
+
+ ) : (
+
+ )}
+
+ )}
+
+ {/* Queue */}
+ {queue.length > 0 && (
+
+
+ Up Next ({queue.length})
+
+
+ {queue.map((item, i) => (
+
+
+ {i + 1}
+
+
+
+ {item.result.title}
+
+
+ {item.result.artist}
+
+
+
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+ )
+}