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:
@@ -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
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
127
frontend/src/pages/SharedView.tsx
Normal file
127
frontend/src/pages/SharedView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user