Remove Listen page, tighten Bandcamp matching to 75%+ artist similarity
This commit is contained in:
BIN
FEATURES.pdf
Normal file
BIN
FEATURES.pdf
Normal file
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user