From 2e26aa03c4d394eb05c0b528b64c79949546f1af Mon Sep 17 00:00:00 2001 From: root Date: Tue, 31 Mar 2026 18:20:43 -0500 Subject: [PATCH] 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 --- backend/app/api/endpoints/recommendations.py | 181 ++++++++++++++++++ frontend/src/App.tsx | 13 ++ .../src/components/RecommendationCard.tsx | 50 ++++- frontend/src/lib/api.ts | 17 ++ frontend/src/pages/SharedView.tsx | 127 ++++++++++++ 5 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/SharedView.tsx diff --git a/backend/app/api/endpoints/recommendations.py b/backend/app/api/endpoints/recommendations.py index 99aa9b3..44d8ad3 100644 --- a/backend/app/api/endpoints/recommendations.py +++ b/backend/app/api/endpoints/recommendations.py @@ -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 + ] + } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index aec93fa..7b2d679 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> + + + + + + } + /> } /> + } /> } /> ) diff --git a/frontend/src/components/RecommendationCard.tsx b/frontend/src/components/RecommendationCard.tsx index bcfe61a..2e51c09 100644 --- a/frontend/src/components/RecommendationCard.tsx +++ b/frontend/src/components/RecommendationCard.tsx @@ -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 (
@@ -80,6 +107,27 @@ export default function RecommendationCard({ recommendation, onToggleSave, onDis )} + + + + {recommendation.youtube_url && ( 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('/recommendations/analyze', { artist, title }).then((r) => r.data) + // YouTube Music Import export interface YouTubeTrackResult { title: string diff --git a/frontend/src/pages/SharedView.tsx b/frontend/src/pages/SharedView.tsx new file mode 100644 index 0000000..9327ec3 --- /dev/null +++ b/frontend/src/pages/SharedView.tsx @@ -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(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +