Remove Listen page, tighten Bandcamp matching to 75%+ artist similarity

This commit is contained in:
root
2026-03-31 00:10:15 -05:00
parent 1efa5cd628
commit 789de25c1a
6 changed files with 9 additions and 381 deletions

BIN
FEATURES.pdf Normal file

Binary file not shown.

View File

@@ -32,21 +32,16 @@ async def search_bandcamp_verified(artist: str, title: str) -> dict | None:
for r in results:
artist_sim = _similarity(r.get("artist", ""), artist)
title_sim = _similarity(r.get("title", ""), title)
# Require artist to be a strong match (>0.6) and title reasonable (>0.4)
if artist_sim >= 0.6 and title_sim >= 0.4:
return r
# Or if artist is very close, accept even if title differs (different track by same artist)
if artist_sim >= 0.8:
# Require artist to be a strong match (>0.75) AND title reasonable (>0.5)
if artist_sim >= 0.75 and title_sim >= 0.5:
return r
# Try artist/band search as fallback
# Try artist/band search as fallback — very strict matching
results = await search_bandcamp(artist, item_type="b")
for r in results:
artist_sim = _similarity(r.get("title", ""), artist) # For band results, title IS the band name
if artist_sim >= 0.6:
return r
artist_sim = _similarity(r.get("artist", ""), artist)
if artist_sim >= 0.6:
# For band results, title IS the band name
name = r.get("title", "") or r.get("artist", "")
if _similarity(name, artist) >= 0.8:
return r
return None

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import { Heart, ExternalLink, Music, Headphones } from 'lucide-react'
import { Link } from 'react-router-dom'
import { Heart, ExternalLink, Music } from 'lucide-react'
import type { RecommendationItem } from '../lib/api'
interface Props {
@@ -61,7 +60,7 @@ export default function RecommendationCard({ recommendation, onToggleSave, savin
/>
</button>
{recommendation.bandcamp_url ? (
{recommendation.bandcamp_url && (
<a
href={recommendation.bandcamp_url}
target="_blank"
@@ -71,14 +70,6 @@ export default function RecommendationCard({ recommendation, onToggleSave, savin
>
<ExternalLink className="w-4 h-4" />
</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>

View File

@@ -1,346 +0,0 @@
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>
)
}