Add Bandcamp/YouTube links on all recs, Fix My Playlist, configurable rec count
- Bandcamp: match artist+song, fall back to artist-only page - YouTube fallback: if not on Bandcamp, link to YouTube music video search - Fix My Playlist: AI analyzes playlist, finds outliers, suggests replacements - User chooses recommendation count (5, 10, 15, 20)
This commit is contained in:
24
backend/alembic/versions/004_add_youtube_url.py
Normal file
24
backend/alembic/versions/004_add_youtube_url.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""Add youtube_url to recommendations
|
||||||
|
|
||||||
|
Revision ID: 004
|
||||||
|
Revises: 003
|
||||||
|
Create Date: 2026-03-30
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "004"
|
||||||
|
down_revision: Union[str, None] = "003"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("recommendations", sa.Column("youtube_url", sa.String(500), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("recommendations", "youtube_url")
|
||||||
@@ -23,7 +23,7 @@ async def generate(
|
|||||||
|
|
||||||
recs, remaining = await generate_recommendations(
|
recs, remaining = await generate_recommendations(
|
||||||
db, user, playlist_id=data.playlist_id, query=data.query, bandcamp_mode=data.bandcamp_mode,
|
db, user, playlist_id=data.playlist_id, query=data.query, bandcamp_mode=data.bandcamp_mode,
|
||||||
mode=data.mode, adventurousness=data.adventurousness, exclude=data.exclude,
|
mode=data.mode, adventurousness=data.adventurousness, exclude=data.exclude, count=data.count,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not recs and remaining == 0:
|
if not recs and remaining == 0:
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class Recommendation(Base):
|
|||||||
preview_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
preview_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
image_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
image_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
bandcamp_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
bandcamp_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
youtube_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
# AI explanation
|
# AI explanation
|
||||||
reason: Mapped[str] = mapped_column(Text)
|
reason: Mapped[str] = mapped_column(Text)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class RecommendationRequest(BaseModel):
|
|||||||
mode: str = "discover" # discover, sonic_twin, era_bridge, deep_cuts, rising
|
mode: str = "discover" # discover, sonic_twin, era_bridge, deep_cuts, rising
|
||||||
adventurousness: int = 3 # 1-5
|
adventurousness: int = 3 # 1-5
|
||||||
exclude: str | None = None # comma-separated genres to exclude
|
exclude: str | None = None # comma-separated genres to exclude
|
||||||
|
count: int = 5 # Number of recommendations (5, 10, 15, 20)
|
||||||
|
|
||||||
|
|
||||||
class RecommendationItem(BaseModel):
|
class RecommendationItem(BaseModel):
|
||||||
@@ -21,6 +22,7 @@ class RecommendationItem(BaseModel):
|
|||||||
preview_url: str | None = None
|
preview_url: str | None = None
|
||||||
image_url: str | None = None
|
image_url: str | None = None
|
||||||
bandcamp_url: str | None = None
|
bandcamp_url: str | None = None
|
||||||
|
youtube_url: str | None = None
|
||||||
reason: str
|
reason: str
|
||||||
score: float | None = None
|
score: float | None = None
|
||||||
saved: bool = False
|
saved: bool = False
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ async def search_bandcamp_verified(artist: str, title: str) -> dict | None:
|
|||||||
"""Search Bandcamp and only return a result if the artist actually matches.
|
"""Search Bandcamp and only return a result if the artist actually matches.
|
||||||
|
|
||||||
Returns the best matching result or None if no good match found.
|
Returns the best matching result or None if no good match found.
|
||||||
|
First tries artist+song, then falls back to artist-only search.
|
||||||
"""
|
"""
|
||||||
# Try track search first: "artist title"
|
# Try track search first: "artist title"
|
||||||
results = await search_bandcamp(f"{artist} {title}", item_type="t")
|
results = await search_bandcamp(f"{artist} {title}", item_type="t")
|
||||||
@@ -36,12 +37,12 @@ async def search_bandcamp_verified(artist: str, title: str) -> dict | None:
|
|||||||
if artist_sim >= 0.75 and title_sim >= 0.5:
|
if artist_sim >= 0.75 and title_sim >= 0.5:
|
||||||
return r
|
return r
|
||||||
|
|
||||||
# Try artist/band search as fallback — very strict matching
|
# Try artist/band search as fallback — return their artist page URL
|
||||||
results = await search_bandcamp(artist, item_type="b")
|
results = await search_bandcamp(artist, item_type="b")
|
||||||
for r in results:
|
for r in results:
|
||||||
# For band results, title IS the band name
|
# For band results, title IS the band name
|
||||||
name = r.get("title", "") or r.get("artist", "")
|
name = r.get("title", "") or r.get("artist", "")
|
||||||
if _similarity(name, artist) >= 0.8:
|
if _similarity(name, artist) >= 0.7:
|
||||||
return r
|
return r
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
import anthropic
|
import anthropic
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
@@ -90,6 +91,7 @@ async def generate_recommendations(
|
|||||||
mode: str = "discover",
|
mode: str = "discover",
|
||||||
adventurousness: int = 3,
|
adventurousness: int = 3,
|
||||||
exclude: str | None = None,
|
exclude: str | None = None,
|
||||||
|
count: int = 5,
|
||||||
) -> tuple[list[Recommendation], int | None]:
|
) -> tuple[list[Recommendation], int | None]:
|
||||||
"""Generate AI music recommendations using Claude."""
|
"""Generate AI music recommendations using Claude."""
|
||||||
|
|
||||||
@@ -184,7 +186,7 @@ Already in their library (do NOT recommend these):
|
|||||||
{disliked_instruction}
|
{disliked_instruction}
|
||||||
{exclude_instruction}
|
{exclude_instruction}
|
||||||
|
|
||||||
Respond with exactly {"15" if bandcamp_mode else "5"} music recommendations as a JSON array. Each item should have:
|
Respond with exactly {count * 3 if bandcamp_mode else count} music recommendations as a JSON array. Each item should have:
|
||||||
- "title": song title
|
- "title": song title
|
||||||
- "artist": artist name
|
- "artist": artist name
|
||||||
- "album": album name (if known)
|
- "album": album name (if known)
|
||||||
@@ -219,33 +221,41 @@ Return ONLY the JSON array, no other text."""
|
|||||||
# Save to DB — in bandcamp mode, only keep results verified on Bandcamp
|
# Save to DB — in bandcamp mode, only keep results verified on Bandcamp
|
||||||
recommendations = []
|
recommendations = []
|
||||||
for rec in recs_data:
|
for rec in recs_data:
|
||||||
if len(recommendations) >= 5:
|
if len(recommendations) >= count:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
artist = rec.get("artist", "Unknown")
|
||||||
|
title = rec.get("title", "Unknown")
|
||||||
bandcamp_url = None
|
bandcamp_url = None
|
||||||
if bandcamp_mode:
|
youtube_url = None
|
||||||
|
|
||||||
|
# Try Bandcamp for every recommendation
|
||||||
try:
|
try:
|
||||||
match = await search_bandcamp_verified(
|
match = await search_bandcamp_verified(artist, title)
|
||||||
rec.get("artist", ""), rec.get("title", "")
|
|
||||||
)
|
|
||||||
if match:
|
if match:
|
||||||
bandcamp_url = match.get("bandcamp_url")
|
bandcamp_url = match.get("bandcamp_url")
|
||||||
else:
|
|
||||||
# Not verified on Bandcamp — skip
|
|
||||||
continue
|
|
||||||
except Exception:
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if bandcamp_mode and not bandcamp_url:
|
||||||
|
# In bandcamp mode, skip recommendations not found on Bandcamp
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# If not on Bandcamp, build a YouTube search link
|
||||||
|
if not bandcamp_url:
|
||||||
|
youtube_url = f"https://www.youtube.com/results?search_query={quote_plus(f'{artist} {title} official music video')}"
|
||||||
|
|
||||||
r = Recommendation(
|
r = Recommendation(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
playlist_id=playlist_id,
|
playlist_id=playlist_id,
|
||||||
title=rec.get("title", "Unknown"),
|
title=title,
|
||||||
artist=rec.get("artist", "Unknown"),
|
artist=artist,
|
||||||
album=rec.get("album"),
|
album=rec.get("album"),
|
||||||
reason=rec.get("reason", ""),
|
reason=rec.get("reason", ""),
|
||||||
score=rec.get("score"),
|
score=rec.get("score"),
|
||||||
query=query,
|
query=query,
|
||||||
bandcamp_url=bandcamp_url,
|
bandcamp_url=bandcamp_url,
|
||||||
|
youtube_url=youtube_url,
|
||||||
)
|
)
|
||||||
db.add(r)
|
db.add(r)
|
||||||
recommendations.append(r)
|
recommendations.append(r)
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export default function RecommendationCard({ recommendation, onToggleSave, onDis
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recommendation.bandcamp_url && (
|
{recommendation.bandcamp_url ? (
|
||||||
<a
|
<a
|
||||||
href={recommendation.bandcamp_url}
|
href={recommendation.bandcamp_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -90,7 +90,17 @@ export default function RecommendationCard({ recommendation, onToggleSave, onDis
|
|||||||
>
|
>
|
||||||
<ExternalLink className="w-4 h-4" />
|
<ExternalLink className="w-4 h-4" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
) : recommendation.youtube_url ? (
|
||||||
|
<a
|
||||||
|
href={recommendation.youtube_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-2 rounded-full bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
|
||||||
|
title="Watch on YouTube"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export default function Discover() {
|
|||||||
const [mode, setMode] = useState('discover')
|
const [mode, setMode] = useState('discover')
|
||||||
const [adventurousness, setAdventurousness] = useState(3)
|
const [adventurousness, setAdventurousness] = useState(3)
|
||||||
const [excludeGenres, setExcludeGenres] = useState('')
|
const [excludeGenres, setExcludeGenres] = useState('')
|
||||||
|
const [count, setCount] = useState(5)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -71,6 +72,7 @@ export default function Discover() {
|
|||||||
mode,
|
mode,
|
||||||
adventurousness,
|
adventurousness,
|
||||||
excludeGenres.trim() || undefined,
|
excludeGenres.trim() || undefined,
|
||||||
|
count,
|
||||||
)
|
)
|
||||||
setResults(response.recommendations)
|
setResults(response.recommendations)
|
||||||
setRemaining(response.remaining_this_week)
|
setRemaining(response.remaining_this_week)
|
||||||
@@ -221,6 +223,28 @@ export default function Discover() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Recommendation Count */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-charcoal mb-3">
|
||||||
|
How many recommendations
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[5, 10, 15, 20].map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
onClick={() => setCount(n)}
|
||||||
|
className={`px-4 py-2 rounded-full text-sm font-medium transition-all cursor-pointer border ${
|
||||||
|
count === n
|
||||||
|
? 'bg-purple text-white border-purple shadow-md shadow-purple/20'
|
||||||
|
: 'bg-white text-charcoal-muted border-purple-100 hover:border-purple/30 hover:text-charcoal'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Block Genres */}
|
{/* Block Genres */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-charcoal mb-2">
|
<label className="block text-sm font-medium text-charcoal mb-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user