Add Bandcamp search and Listening Room page

Implement Bandcamp search service with autocomplete API and HTML
scraping fallback. Add /api/bandcamp/search and /api/bandcamp/embed
endpoints. Create Listening Room page with search, embedded player,
and queue management. Add navigation entry and Bandcamp link on
recommendation cards.
This commit is contained in:
root
2026-03-30 23:38:14 -05:00
parent 3303cd1507
commit dd4df6a070
8 changed files with 673 additions and 3 deletions

View File

@@ -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"),
)

View File

@@ -2,7 +2,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings 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") 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(youtube_music.router, prefix="/api")
app.include_router(manual_import.router, prefix="/api") app.include_router(manual_import.router, prefix="/api")
app.include_router(lastfm.router, prefix="/api") app.include_router(lastfm.router, prefix="/api")
app.include_router(bandcamp.router, prefix="/api")
@app.get("/api/health") @app.get("/api/health")

View File

@@ -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'<li\s+class="searchresult\s', html)
for item_html in items[1:]: # skip first split (before first result)
# Extract title and URL from heading link
heading_match = re.search(
r'class="heading">\s*<a\s+href="([^"]+)"[^>]*>\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*<img\s+src="([^"]+)"', item_html
)
art_url = art_match.group(1).strip() if art_match else None
results.append({
"title": title,
"artist": artist,
"art_url": art_url,
"bandcamp_url": url,
"item_type": item_type,
})
if len(results) >= 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: <meta property="og:video" content="...album=12345..." />
# 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'<meta\s+property="og:video"\s+content="[^"]*(?:album|track)=(\d+)',
html,
)
if og_match:
item_id = og_match.group(1)
if not item_id:
# Try the embedded player link in the page
embed_match = re.search(
r'EmbeddedPlayer/(?:album|track)=(\d+)', html
)
if embed_match:
item_id = embed_match.group(1)
if not item_id:
return None
# Build embed URL
id_type = "track" if is_track else "album"
embed_url = (
f"https://bandcamp.com/EmbeddedPlayer/"
f"{id_type}={item_id}/size=large/"
f"bgcol=1C1917/linkcol=7C3AED/"
f"tracklist=false/transparent=true/"
)
# Extract title from og:title
title = ""
title_match = re.search(
r'<meta\s+property="og:title"\s+content="([^"]+)"', html
)
if title_match:
title = title_match.group(1).strip()
# Extract artist
artist = ""
artist_match = re.search(
r'<meta\s+property="og:site_name"\s+content="([^"]+)"', html
)
if artist_match:
artist = artist_match.group(1).strip()
# Extract art
art_url = None
art_match = re.search(
r'<meta\s+property="og:image"\s+content="([^"]+)"', html
)
if art_match:
art_url = art_match.group(1).strip()
return {
"embed_url": embed_url,
"title": title,
"artist": artist,
"art_url": art_url,
"item_id": item_id,
"item_type": id_type,
}
except Exception:
return None

View File

@@ -11,6 +11,7 @@ import PlaylistDetail from './pages/PlaylistDetail'
import Discover from './pages/Discover' import Discover from './pages/Discover'
import Recommendations from './pages/Recommendations' import Recommendations from './pages/Recommendations'
import Billing from './pages/Billing' import Billing from './pages/Billing'
import ListeningRoom from './pages/ListeningRoom'
function RootRedirect() { function RootRedirect() {
const { user, loading } = useAuth() const { user, loading } = useAuth()
@@ -83,6 +84,16 @@ function AppRoutes() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/listen"
element={
<ProtectedRoute>
<Layout>
<ListeningRoom />
</Layout>
</ProtectedRoute>
}
/>
<Route <Route
path="/billing" path="/billing"
element={ element={

View File

@@ -1,12 +1,13 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom' import { Link, useLocation, useNavigate } from 'react-router-dom'
import { Disc3, LayoutDashboard, ListMusic, Compass, Heart, Crown, Menu, X, LogOut, User } from 'lucide-react' import { Disc3, LayoutDashboard, ListMusic, Compass, Heart, Crown, Headphones, Menu, X, LogOut, User } from 'lucide-react'
import { useAuth } from '../lib/auth' import { useAuth } from '../lib/auth'
const navItems = [ const navItems = [
{ path: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }, { path: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ path: '/playlists', label: 'Playlists', icon: ListMusic }, { path: '/playlists', label: 'Playlists', icon: ListMusic },
{ path: '/discover', label: 'Discover', icon: Compass }, { path: '/discover', label: 'Discover', icon: Compass },
{ path: '/listen', label: 'Listen', icon: Headphones },
{ path: '/saved', label: 'Saved', icon: Heart }, { path: '/saved', label: 'Saved', icon: Heart },
{ path: '/billing', label: 'Pro', icon: Crown }, { path: '/billing', label: 'Pro', icon: Crown },
] ]

View File

@@ -1,4 +1,5 @@
import { Heart, ExternalLink, Music } from 'lucide-react' import { Heart, ExternalLink, Music, Headphones } from 'lucide-react'
import { Link } from 'react-router-dom'
import type { RecommendationItem } from '../lib/api' import type { RecommendationItem } from '../lib/api'
interface Props { interface Props {
@@ -71,6 +72,14 @@ export default function RecommendationCard({ recommendation, onToggleSave, savin
<ExternalLink className="w-4 h-4" /> <ExternalLink className="w-4 h-4" />
</a> </a>
)} )}
<Link
to={`/listen?q=${encodeURIComponent(`${recommendation.artist} ${recommendation.title}`)}`}
className="p-2 rounded-full bg-purple-50 text-purple-400 hover:bg-purple-100 hover:text-purple transition-colors"
title="Find on Bandcamp"
>
<Headphones className="w-4 h-4" />
</Link>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -211,4 +211,30 @@ export const createBillingPortal = () =>
export const getBillingStatus = () => export const getBillingStatus = () =>
api.get<BillingStatusResponse>('/billing/status').then((r) => r.data) api.get<BillingStatusResponse>('/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<BandcampResult[]> {
const { data } = await api.get('/bandcamp/search', { params: { q: query, type } })
return data
}
export async function getBandcampEmbed(url: string): Promise<BandcampEmbed> {
const { data } = await api.get('/bandcamp/embed', { params: { url } })
return data
}
export default api export default api

View File

@@ -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<SearchType>('t')
const [results, setResults] = useState<BandcampResult[]>([])
const [searching, setSearching] = useState(false)
const [searchError, setSearchError] = useState('')
const [currentPlayer, setCurrentPlayer] = useState<QueueItem | null>(null)
const [queue, setQueue] = useState<QueueItem[]>([])
// 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<SearchType, string> = { t: 'Tracks', a: 'Albums', b: 'Artists' }
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-purple flex items-center justify-center">
<Headphones className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-charcoal">Listening Room</h1>
<p className="text-charcoal-muted text-sm">
Discover and listen to music on Bandcamp
</p>
</div>
</div>
{/* Search Section */}
<div className="bg-white rounded-2xl border border-purple-100 shadow-sm p-5">
<form
onSubmit={(e) => {
e.preventDefault()
handleSearch()
}}
className="flex gap-3"
>
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-charcoal-muted" />
<input
type="text"
value={query}
onChange={(e) => 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"
/>
</div>
<button
type="submit"
disabled={searching || !query.trim()}
className="px-6 py-3 bg-purple text-white rounded-xl font-medium hover:bg-purple-700 transition-colors cursor-pointer border-none disabled:opacity-50 disabled:cursor-not-allowed"
>
{searching ? 'Searching...' : 'Search'}
</button>
</form>
{/* Type Toggle */}
<div className="flex gap-2 mt-3">
{(Object.entries(typeLabels) as [SearchType, string][]).map(([type, label]) => (
<button
key={type}
onClick={() => setSearchType(type)}
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-colors cursor-pointer border-none ${
searchType === type
? 'bg-purple text-white'
: 'bg-purple-50 text-purple hover:bg-purple-100'
}`}
>
{label}
</button>
))}
</div>
</div>
{/* Search Results */}
{searchError && (
<div className="text-center py-8 text-charcoal-muted">
<Music className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>{searchError}</p>
</div>
)}
{results.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-charcoal mb-4">
Results ({results.length})
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{results.map((result, i) => (
<div
key={`${result.bandcamp_url}-${i}`}
className="bg-white rounded-2xl border border-purple-100 shadow-sm hover:shadow-md transition-shadow overflow-hidden"
>
<div className="flex gap-3 p-4">
{/* Art */}
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-purple-200 to-purple-400 flex-shrink-0 flex items-center justify-center overflow-hidden">
{result.art_url ? (
<img
src={result.art_url}
alt={result.title}
className="w-full h-full object-cover"
/>
) : (
<Disc3 className="w-7 h-7 text-white/80" />
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-charcoal text-sm truncate">
{result.title}
</h3>
<p className="text-charcoal-muted text-xs truncate">
{result.artist}
</p>
<div className="flex gap-2 mt-2">
<button
onClick={() => handleListen(result)}
className="flex items-center gap-1 px-3 py-1 bg-purple text-white rounded-lg text-xs font-medium hover:bg-purple-700 transition-colors cursor-pointer border-none"
>
<Play className="w-3 h-3" />
Listen
</button>
<button
onClick={() => addToQueue(result)}
className="px-3 py-1 bg-purple-50 text-purple rounded-lg text-xs font-medium hover:bg-purple-100 transition-colors cursor-pointer border-none"
>
+ Queue
</button>
<a
href={result.bandcamp_url}
target="_blank"
rel="noopener noreferrer"
className="p-1 rounded-lg text-charcoal-muted hover:text-purple hover:bg-purple-50 transition-colors"
title="Open on Bandcamp"
>
<ExternalLink className="w-4 h-4" />
</a>
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Player Section */}
{(currentPlayer || queue.length > 0) && (
<div className="bg-charcoal rounded-2xl shadow-lg overflow-hidden">
{/* Now Playing */}
{currentPlayer && (
<div className="p-6">
<div className="flex items-center gap-2 mb-4">
<Headphones className="w-5 h-5 text-purple" />
<h2 className="text-lg font-semibold text-white">Now Playing</h2>
</div>
{currentPlayer.loading ? (
<div className="flex items-center justify-center py-12">
<div className="w-10 h-10 border-4 border-purple border-t-transparent rounded-full animate-spin" />
</div>
) : currentPlayer.embed ? (
<div className="space-y-4">
<div className="flex items-center gap-4 mb-4">
{currentPlayer.embed.art_url && (
<img
src={currentPlayer.embed.art_url}
alt={currentPlayer.embed.title}
className="w-16 h-16 rounded-xl object-cover"
/>
)}
<div className="min-w-0">
<h3 className="font-semibold text-white truncate">
{currentPlayer.embed.title}
</h3>
<p className="text-purple-200 text-sm truncate">
{currentPlayer.embed.artist}
</p>
</div>
<a
href={currentPlayer.result.bandcamp_url}
target="_blank"
rel="noopener noreferrer"
className="ml-auto flex items-center gap-1 px-3 py-1.5 bg-purple/20 text-purple-200 rounded-lg text-xs hover:bg-purple/30 transition-colors no-underline"
>
<ExternalLink className="w-3 h-3" />
Bandcamp
</a>
</div>
<iframe
style={{ border: 0, width: '100%', height: `${embedHeight}px` }}
src={currentPlayer.embed.embed_url}
seamless
title={`${currentPlayer.embed.title} by ${currentPlayer.embed.artist}`}
/>
</div>
) : (
<div className="text-center py-8 text-purple-200/60">
<p>Could not load player. Try opening on Bandcamp directly.</p>
<a
href={currentPlayer.result.bandcamp_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 mt-2 text-purple-200 hover:text-white transition-colors"
>
<ExternalLink className="w-4 h-4" />
Open on Bandcamp
</a>
</div>
)}
</div>
)}
{/* Queue */}
{queue.length > 0 && (
<div className={`border-t border-white/10 p-6 ${!currentPlayer ? 'pt-6' : ''}`}>
<h3 className="text-sm font-semibold text-purple-200 uppercase tracking-wide mb-3">
Up Next ({queue.length})
</h3>
<div className="space-y-2">
{queue.map((item, i) => (
<div
key={`${item.result.bandcamp_url}-${i}`}
className="flex items-center gap-3 bg-white/5 rounded-xl px-4 py-3 hover:bg-white/10 transition-colors group"
>
<span className="text-xs text-purple-200/50 w-5 text-center">
{i + 1}
</span>
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-medium truncate">
{item.result.title}
</p>
<p className="text-purple-200/60 text-xs truncate">
{item.result.artist}
</p>
</div>
<button
onClick={() => playFromQueue(i)}
className="p-1.5 rounded-lg text-purple-200 hover:text-white hover:bg-purple/30 transition-colors cursor-pointer bg-transparent border-none opacity-0 group-hover:opacity-100"
title="Play now"
>
<Play className="w-4 h-4" />
</button>
<button
onClick={() => removeFromQueue(i)}
className="p-1.5 rounded-lg text-purple-200/50 hover:text-red-400 hover:bg-red-400/10 transition-colors cursor-pointer bg-transparent border-none opacity-0 group-hover:opacity-100"
title="Remove from queue"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
)
}