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
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user