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 fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db from app.core.database import get_db
from app.core.security import get_current_user from app.core.security import get_current_user
from app.models.user import User from app.models.user import User
@@ -62,6 +69,90 @@ async def saved(
return result.scalars().all() 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") @router.post("/{rec_id}/save")
async def save_recommendation( async def save_recommendation(
rec_id: int, rec_id: int,
@@ -92,3 +183,93 @@ async def dislike_recommendation(
raise HTTPException(status_code=404, detail="Recommendation not found") raise HTTPException(status_code=404, detail="Recommendation not found")
rec.disliked = not rec.disliked rec.disliked = not rec.disliked
return {"disliked": 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 Recommendations from './pages/Recommendations'
import Billing from './pages/Billing' import Billing from './pages/Billing'
import TasteProfilePage from './pages/TasteProfilePage' import TasteProfilePage from './pages/TasteProfilePage'
import Analyze from './pages/Analyze'
import BandcampDiscover from './pages/BandcampDiscover' import BandcampDiscover from './pages/BandcampDiscover'
import Admin from './pages/Admin' import Admin from './pages/Admin'
import SharedView from './pages/SharedView'
function RootRedirect() { function RootRedirect() {
const { user, loading } = useAuth() const { user, loading } = useAuth()
@@ -84,6 +86,16 @@ function AppRoutes() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/analyze"
element={
<ProtectedRoute>
<Layout>
<Analyze />
</Layout>
</ProtectedRoute>
}
/>
<Route <Route
path="/bandcamp" path="/bandcamp"
element={ element={
@@ -124,6 +136,7 @@ function AppRoutes() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route path="/shared/:recId/:token" element={<SharedView />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </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 type { RecommendationItem } from '../lib/api'
import { shareRecommendation } from '../lib/api'
interface Props { interface Props {
recommendation: RecommendationItem recommendation: RecommendationItem
@@ -10,6 +13,30 @@ interface Props {
} }
export default function RecommendationCard({ recommendation, onToggleSave, onDislike, saving, disliking }: 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 ( return (
<div className="bg-white rounded-2xl border border-purple-100 shadow-sm hover:shadow-md transition-shadow overflow-hidden"> <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"> <div className="flex gap-4 p-5">
@@ -80,6 +107,27 @@ export default function RecommendationCard({ recommendation, onToggleSave, onDis
</button> </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 && ( {recommendation.youtube_url && (
<a <a
href={recommendation.youtube_url} href={recommendation.youtube_url}

View File

@@ -200,6 +200,23 @@ export const toggleSaveRecommendation = (id: string) =>
export const dislikeRecommendation = (id: string) => export const dislikeRecommendation = (id: string) =>
api.post<{ disliked: boolean }>(`/recommendations/${id}/dislike`).then((r) => r.data) 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 // YouTube Music Import
export interface YouTubeTrackResult { export interface YouTubeTrackResult {
title: string 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>
)
}