Add share discoveries feature with public share links

- Add single and batch share endpoints with signed URL tokens
- Add public view endpoints (no auth required) for shared recommendations
- Add share button with clipboard copy to RecommendationCard
- Create SharedView page with Vynl branding and registration CTA
- Add /shared/:recId/:token public route in App.tsx
- Add shareRecommendation and getSharedRecommendation API functions
This commit is contained in:
root
2026-03-31 18:20:43 -05:00
parent 3bab0b5911
commit 2e26aa03c4
5 changed files with 387 additions and 1 deletions

View File

@@ -1,7 +1,14 @@
import hashlib
import json
from urllib.parse import quote_plus
import anthropic
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User
@@ -62,6 +69,90 @@ async def saved(
return result.scalars().all()
class AnalyzeRequest(BaseModel):
artist: str
title: str
class AnalyzeResponse(BaseModel):
analysis: str
qualities: list[str]
recommendations: list[RecommendationItem]
@router.post("/analyze", response_model=AnalyzeResponse)
async def analyze_song(
data: AnalyzeRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
prompt = f"""You are Vynl, a music analysis expert. The user wants to understand why they love this song:
Artist: {data.artist}
Title: {data.title}
Respond with a JSON object:
{{
"analysis": "A warm, insightful 3-4 sentence explanation of what makes this song special and why someone would be drawn to it. Reference specific sonic qualities, production choices, lyrical themes, and emotional resonance.",
"qualities": ["quality1", "quality2", ...],
"recommendations": [
{{"title": "...", "artist": "...", "album": "...", "reason": "...", "score": 0.9}}
]
}}
For "qualities", list 4-6 specific musical qualities (e.g., "warm analog production", "introspective lyrics about loss", "driving bass line with syncopated rhythm").
For "recommendations", suggest 5 songs that share these same qualities. Only suggest songs that actually exist.
Return ONLY the JSON object."""
client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)
message = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=2000,
messages=[{"role": "user", "content": prompt}],
)
response_text = message.content[0].text.strip()
if response_text.startswith("```"):
response_text = response_text.split("\n", 1)[1]
response_text = response_text.rsplit("```", 1)[0]
try:
parsed = json.loads(response_text)
except json.JSONDecodeError:
raise HTTPException(status_code=500, detail="Failed to parse AI response")
analysis = parsed.get("analysis", "")
qualities = parsed.get("qualities", [])
recs_data = parsed.get("recommendations", [])
recommendations = []
for rec in recs_data[:5]:
artist = rec.get("artist", "Unknown")
title = rec.get("title", "Unknown")
youtube_url = f"https://music.youtube.com/search?q={quote_plus(f'{artist} {title}')}"
r = Recommendation(
user_id=user.id,
title=title,
artist=artist,
album=rec.get("album"),
reason=rec.get("reason", ""),
score=rec.get("score"),
query=f"analyze: {data.artist} - {data.title}",
youtube_url=youtube_url,
)
db.add(r)
recommendations.append(r)
await db.flush()
return AnalyzeResponse(
analysis=analysis,
qualities=qualities,
recommendations=[RecommendationItem.model_validate(r) for r in recommendations],
)
@router.post("/{rec_id}/save")
async def save_recommendation(
rec_id: int,
@@ -92,3 +183,93 @@ async def dislike_recommendation(
raise HTTPException(status_code=404, detail="Recommendation not found")
rec.disliked = not rec.disliked
return {"disliked": rec.disliked}
@router.post("/{rec_id}/share")
async def share_recommendation(
rec_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Generate a share link for a recommendation."""
result = await db.execute(
select(Recommendation).where(Recommendation.id == rec_id, Recommendation.user_id == user.id)
)
rec = result.scalar_one_or_none()
if not rec:
raise HTTPException(status_code=404, detail="Recommendation not found")
token = hashlib.sha256(f"{rec_id}:{settings.SECRET_KEY}".encode()).hexdigest()[:16]
return {"share_url": f"{settings.FRONTEND_URL}/shared/{rec_id}/{token}"}
@router.get("/shared/{rec_id}/{token}")
async def get_shared_recommendation(
rec_id: int,
token: str,
db: AsyncSession = Depends(get_db),
):
"""View a shared recommendation (no auth required)."""
expected = hashlib.sha256(f"{rec_id}:{settings.SECRET_KEY}".encode()).hexdigest()[:16]
if token != expected:
raise HTTPException(status_code=404, detail="Invalid share link")
result = await db.execute(
select(Recommendation).where(Recommendation.id == rec_id)
)
rec = result.scalar_one_or_none()
if not rec:
raise HTTPException(status_code=404, detail="Recommendation not found")
return {
"title": rec.title,
"artist": rec.artist,
"album": rec.album,
"reason": rec.reason,
"youtube_url": rec.youtube_url,
"image_url": rec.image_url,
}
@router.post("/share-batch")
async def share_batch(
rec_ids: list[int],
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Generate a share link for multiple recommendations."""
ids_str = ",".join(str(i) for i in sorted(rec_ids))
token = hashlib.sha256(f"batch:{ids_str}:{settings.SECRET_KEY}".encode()).hexdigest()[:16]
return {"share_url": f"{settings.FRONTEND_URL}/shared/batch/{ids_str}/{token}"}
@router.get("/shared/batch/{ids_str}/{token}")
async def get_shared_batch(
ids_str: str,
token: str,
db: AsyncSession = Depends(get_db),
):
"""View shared recommendations (no auth required)."""
expected = hashlib.sha256(f"batch:{ids_str}:{settings.SECRET_KEY}".encode()).hexdigest()[:16]
if token != expected:
raise HTTPException(status_code=404, detail="Invalid share link")
rec_ids = [int(i) for i in ids_str.split(",")]
result = await db.execute(
select(Recommendation).where(Recommendation.id.in_(rec_ids))
)
recs = result.scalars().all()
return {
"recommendations": [
{
"title": r.title,
"artist": r.artist,
"album": r.album,
"reason": r.reason,
"youtube_url": r.youtube_url,
"image_url": r.image_url,
}
for r in recs
]
}

View File

@@ -11,8 +11,10 @@ import Discover from './pages/Discover'
import Recommendations from './pages/Recommendations'
import Billing from './pages/Billing'
import TasteProfilePage from './pages/TasteProfilePage'
import Analyze from './pages/Analyze'
import BandcampDiscover from './pages/BandcampDiscover'
import Admin from './pages/Admin'
import SharedView from './pages/SharedView'
function RootRedirect() {
const { user, loading } = useAuth()
@@ -84,6 +86,16 @@ function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/analyze"
element={
<ProtectedRoute>
<Layout>
<Analyze />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/bandcamp"
element={
@@ -124,6 +136,7 @@ function AppRoutes() {
</ProtectedRoute>
}
/>
<Route path="/shared/:recId/:token" element={<SharedView />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)

View File

@@ -1,5 +1,8 @@
import { Heart, ExternalLink, Music, ThumbsDown } from 'lucide-react'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Heart, ExternalLink, Music, ThumbsDown, Repeat, Share2, Check } from 'lucide-react'
import type { RecommendationItem } from '../lib/api'
import { shareRecommendation } from '../lib/api'
interface Props {
recommendation: RecommendationItem
@@ -10,6 +13,30 @@ interface Props {
}
export default function RecommendationCard({ recommendation, onToggleSave, onDislike, saving, disliking }: Props) {
const navigate = useNavigate()
const [sharing, setSharing] = useState(false)
const [shared, setShared] = useState(false)
const handleMoreLikeThis = () => {
const q = `find songs similar to ${recommendation.artist} - ${recommendation.title}`
navigate(`/discover?q=${encodeURIComponent(q)}`)
}
const handleShare = async () => {
if (sharing) return
setSharing(true)
try {
const { share_url } = await shareRecommendation(recommendation.id)
await navigator.clipboard.writeText(share_url)
setShared(true)
setTimeout(() => setShared(false), 2000)
} catch {
// Fallback: if clipboard fails, silently ignore
} finally {
setSharing(false)
}
}
return (
<div className="bg-white rounded-2xl border border-purple-100 shadow-sm hover:shadow-md transition-shadow overflow-hidden">
<div className="flex gap-4 p-5">
@@ -80,6 +107,27 @@ export default function RecommendationCard({ recommendation, onToggleSave, onDis
</button>
)}
<button
onClick={handleShare}
disabled={sharing}
className={`p-2 rounded-full transition-colors cursor-pointer border-none ${
shared
? 'bg-green-50 text-green-600'
: 'bg-purple-50 text-purple-600 hover:bg-purple-100 hover:text-purple-700'
} ${sharing ? 'opacity-50 cursor-not-allowed' : ''}`}
title={shared ? 'Link copied!' : 'Share'}
>
{shared ? <Check className="w-4 h-4" /> : <Share2 className="w-4 h-4" />}
</button>
<button
onClick={handleMoreLikeThis}
className="p-2 rounded-full bg-purple-50 text-purple-600 hover:bg-purple-100 hover:text-purple-700 transition-colors cursor-pointer border-none"
title="More like this"
>
<Repeat className="w-4 h-4" />
</button>
{recommendation.youtube_url && (
<a
href={recommendation.youtube_url}

View File

@@ -200,6 +200,23 @@ export const toggleSaveRecommendation = (id: string) =>
export const dislikeRecommendation = (id: string) =>
api.post<{ disliked: boolean }>(`/recommendations/${id}/dislike`).then((r) => r.data)
// Sharing
export const shareRecommendation = (id: string) =>
api.post<{ share_url: string }>(`/recommendations/${id}/share`).then((r) => r.data)
export const getSharedRecommendation = (recId: string, token: string) =>
api.get<{ title: string; artist: string; album: string | null; reason: string; youtube_url: string | null; image_url: string | null }>(`/recommendations/shared/${recId}/${token}`).then((r) => r.data)
// Analyze Song
export interface AnalyzeResponse {
analysis: string
qualities: string[]
recommendations: RecommendationItem[]
}
export const analyzeSong = (artist: string, title: string) =>
api.post<AnalyzeResponse>('/recommendations/analyze', { artist, title }).then((r) => r.data)
// YouTube Music Import
export interface YouTubeTrackResult {
title: string

View File

@@ -0,0 +1,127 @@
import { useEffect, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { Music, ExternalLink } from 'lucide-react'
import { getSharedRecommendation } from '../lib/api'
interface SharedRec {
title: string
artist: string
album: string | null
reason: string
youtube_url: string | null
image_url: string | null
}
export default function SharedView() {
const { recId, token } = useParams<{ recId: string; token: string }>()
const [rec, setRec] = useState<SharedRec | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!recId || !token) return
setLoading(true)
getSharedRecommendation(recId, token)
.then(setRec)
.catch(() => setError('This share link is invalid or has expired.'))
.finally(() => setLoading(false))
}, [recId, token])
if (loading) {
return (
<div className="min-h-screen bg-[#FFF7ED] flex items-center justify-center">
<div className="w-12 h-12 border-4 border-[#7C3AED] border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (error || !rec) {
return (
<div className="min-h-screen bg-[#FFF7ED] flex items-center justify-center p-6">
<div className="text-center max-w-md">
<h1 className="text-2xl font-bold text-[#1C1917] mb-2">Link Not Found</h1>
<p className="text-[#1C1917]/60 mb-6">{error || 'This recommendation could not be found.'}</p>
<Link
to="/"
className="inline-block px-6 py-3 bg-[#7C3AED] text-white rounded-xl font-semibold hover:bg-[#6D28D9] transition-colors no-underline"
>
Discover Music on Vynl
</Link>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-[#FFF7ED] flex flex-col">
{/* Header */}
<header className="py-6 px-6 text-center">
<Link to="/" className="inline-block no-underline">
<h1 className="text-2xl font-bold text-[#7C3AED] tracking-tight">vynl</h1>
</Link>
<p className="text-sm text-[#1C1917]/50 mt-1">A friend shared a discovery with you</p>
</header>
{/* Card */}
<main className="flex-1 flex items-start justify-center px-6 pb-12">
<div className="w-full max-w-lg bg-white rounded-2xl shadow-lg border border-[#7C3AED]/10 overflow-hidden">
{/* Album Art / Header */}
<div className="bg-gradient-to-br from-[#7C3AED] to-[#6D28D9] p-8 flex items-center gap-5">
<div className="w-24 h-24 rounded-xl bg-white/20 flex-shrink-0 flex items-center justify-center overflow-hidden">
{rec.image_url ? (
<img
src={rec.image_url}
alt={`${rec.title} cover`}
className="w-full h-full object-cover"
/>
) : (
<Music className="w-10 h-10 text-white/70" />
)}
</div>
<div className="min-w-0">
<h2 className="text-xl font-bold text-white truncate">{rec.title}</h2>
<p className="text-white/80 text-base truncate">{rec.artist}</p>
{rec.album && (
<p className="text-white/60 text-sm truncate mt-1">{rec.album}</p>
)}
</div>
</div>
{/* Reason */}
<div className="p-6">
<h3 className="text-sm font-semibold text-[#7C3AED] uppercase tracking-wide mb-2">
Why you might love this
</h3>
<p className="text-[#1C1917] leading-relaxed">{rec.reason}</p>
{/* YouTube link */}
{rec.youtube_url && (
<a
href={rec.youtube_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 mt-5 px-5 py-2.5 bg-red-50 text-red-600 rounded-xl font-medium hover:bg-red-100 transition-colors no-underline text-sm"
>
<ExternalLink className="w-4 h-4" />
Listen on YouTube Music
</a>
)}
</div>
{/* CTA */}
<div className="border-t border-[#7C3AED]/10 p-6 text-center bg-[#FFF7ED]/50">
<p className="text-[#1C1917]/60 text-sm mb-3">
Want personalized music discoveries powered by AI?
</p>
<Link
to="/register"
className="inline-block px-6 py-3 bg-[#7C3AED] text-white rounded-xl font-semibold hover:bg-[#6D28D9] transition-colors no-underline"
>
Discover More on Vynl
</Link>
</div>
</div>
</main>
</div>
)
}