From 0ee8f9a1445cd743cc0daf57b783a8d03367ef9c Mon Sep 17 00:00:00 2001 From: root Date: Tue, 31 Mar 2026 20:51:12 -0500 Subject: [PATCH] Add public shareable taste profiles Users can generate a share link for their taste profile via the "Share My Taste" button. The link opens a public page showing listening personality, genre breakdown, audio features, and top artists with a CTA to register. Token-based URL prevents enumeration. --- backend/app/api/endpoints/profile.py | 166 ++++++++++++++++- frontend/src/App.tsx | 2 + frontend/src/lib/api.ts | 1 + frontend/src/pages/PublicProfile.tsx | 232 ++++++++++++++++++++++++ frontend/src/pages/TasteProfilePage.tsx | 53 +++++- 5 files changed, 443 insertions(+), 11 deletions(-) create mode 100644 frontend/src/pages/PublicProfile.tsx diff --git a/backend/app/api/endpoints/profile.py b/backend/app/api/endpoints/profile.py index ed4a81d..2e1a07c 100644 --- a/backend/app/api/endpoints/profile.py +++ b/backend/app/api/endpoints/profile.py @@ -1,7 +1,10 @@ -from fastapi import APIRouter, Depends +import hashlib + +from fastapi import APIRouter, Depends, HTTPException 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 @@ -198,3 +201,164 @@ async def get_taste_profile( "track_count": len(all_tracks), "playlist_count": len(playlists), } + + +async def _build_taste_profile(user_id: int, db: AsyncSession) -> dict: + """Build a taste profile dict for the given user_id (shared logic).""" + result = await db.execute( + select(Playlist).where(Playlist.user_id == user_id) + ) + playlists = list(result.scalars().all()) + + all_tracks = [] + for p in playlists: + result = await db.execute(select(Track).where(Track.playlist_id == p.id)) + all_tracks.extend(result.scalars().all()) + + if not all_tracks: + return { + "genre_breakdown": [], + "audio_features": { + "energy": 0, + "danceability": 0, + "valence": 0, + "acousticness": 0, + "avg_tempo": 0, + }, + "personality": { + "label": "New Listener", + "description": "Import some playlists to discover your listening personality!", + "icon": "music", + }, + "top_artists": [], + "track_count": 0, + "playlist_count": len(playlists), + } + + # Genre breakdown + genres_count: dict[str, int] = {} + for t in all_tracks: + if t.genres: + for g in t.genres: + genres_count[g] = genres_count.get(g, 0) + 1 + + total_genre_mentions = sum(genres_count.values()) or 1 + top_genres = sorted(genres_count.items(), key=lambda x: x[1], reverse=True)[:10] + genre_breakdown = [ + {"name": g, "percentage": round((c / total_genre_mentions) * 100, 1)} + for g, c in top_genres + ] + + # Audio features averages + variance + energies = [] + danceabilities = [] + valences = [] + acousticnesses = [] + tempos = [] + + for t in all_tracks: + if t.energy is not None: + energies.append(t.energy) + if t.danceability is not None: + danceabilities.append(t.danceability) + if t.valence is not None: + valences.append(t.valence) + if t.acousticness is not None: + acousticnesses.append(t.acousticness) + if t.tempo is not None: + tempos.append(t.tempo) + + def avg(lst: list[float]) -> float: + return round(sum(lst) / len(lst), 3) if lst else 0 + + def variance(lst: list[float]) -> float: + if len(lst) < 2: + return 0 + m = sum(lst) / len(lst) + return sum((x - m) ** 2 for x in lst) / len(lst) + + avg_energy = avg(energies) + avg_danceability = avg(danceabilities) + avg_valence = avg(valences) + avg_acousticness = avg(acousticnesses) + avg_tempo = round(avg(tempos), 0) + + # Personality + personality = _determine_personality( + genre_count=len(genres_count), + avg_energy=avg_energy, + avg_valence=avg_valence, + avg_acousticness=avg_acousticness, + energy_variance=variance(energies), + valence_variance=variance(valences), + ) + + # Top artists + artist_count: dict[str, int] = {} + for t in all_tracks: + artist_count[t.artist] = artist_count.get(t.artist, 0) + 1 + top_artists_sorted = sorted(artist_count.items(), key=lambda x: x[1], reverse=True)[:8] + + artist_genres: dict[str, str] = {} + for t in all_tracks: + if t.artist in dict(top_artists_sorted) and t.genres and t.artist not in artist_genres: + artist_genres[t.artist] = t.genres[0] + + top_artists = [ + { + "name": name, + "track_count": count, + "genre": artist_genres.get(name, ""), + } + for name, count in top_artists_sorted + ] + + return { + "genre_breakdown": genre_breakdown, + "audio_features": { + "energy": round(avg_energy * 100), + "danceability": round(avg_danceability * 100), + "valence": round(avg_valence * 100), + "acousticness": round(avg_acousticness * 100), + "avg_tempo": avg_tempo, + }, + "personality": personality, + "top_artists": top_artists, + "track_count": len(all_tracks), + "playlist_count": len(playlists), + } + + +def _generate_profile_token(user_id: int) -> str: + """Generate a deterministic share token for a user's profile.""" + return hashlib.sha256( + f"profile:{user_id}:{settings.SECRET_KEY}".encode() + ).hexdigest()[:16] + + +@router.get("/share-link") +async def get_profile_share_link(user: User = Depends(get_current_user)): + """Generate a share link for the user's taste profile.""" + token = _generate_profile_token(user.id) + return {"share_url": f"{settings.FRONTEND_URL}/taste/{user.id}/{token}"} + + +@router.get("/public/{user_id}/{token}") +async def get_public_profile( + user_id: int, + token: str, + db: AsyncSession = Depends(get_db), +): + """Public taste profile — no auth required.""" + expected = _generate_profile_token(user_id) + if token != expected: + raise HTTPException(status_code=404, detail="Invalid profile link") + + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="Profile not found") + + profile = await _build_taste_profile(user_id, db) + profile["name"] = user.name.split()[0] # First name only for privacy + return profile diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8f3dad7..ba365e5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ import Compatibility from './pages/Compatibility' import CrateDigger from './pages/CrateDigger' import RabbitHole from './pages/RabbitHole' import Settings from './pages/Settings' +import PublicProfile from './pages/PublicProfile' function RootRedirect() { const { user, loading } = useAuth() @@ -214,6 +215,7 @@ function AppRoutes() { } /> } /> + } /> } /> ) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index bef10ff..1a824ca 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -448,6 +448,7 @@ export interface AdminStats { users: { total: number; pro: number; free: number } playlists: { total: number; total_tracks: number } recommendations: { total: number; today: number; this_week: number; this_month: number; saved: number; disliked: number } + api_costs: { total_estimated: number; today_estimated: number; total_input_tokens: number; total_output_tokens: number } user_breakdown: { id: number; name: string; email: string; is_pro: boolean; created_at: string; recommendation_count: number }[] } diff --git a/frontend/src/pages/PublicProfile.tsx b/frontend/src/pages/PublicProfile.tsx new file mode 100644 index 0000000..f4154b7 --- /dev/null +++ b/frontend/src/pages/PublicProfile.tsx @@ -0,0 +1,232 @@ +import { useState, useEffect } from 'react' +import { useParams, Link } from 'react-router-dom' +import { Loader2, Fingerprint, Zap, Music2, CloudSun, Heart, Layers, Globe, Drama, User } from 'lucide-react' +import { getPublicProfile, type TasteProfileResponse } from '../lib/api' + +const personalityIcons: Record = { + zap: Zap, + cloud: CloudSun, + heart: Heart, + layers: Layers, + globe: Globe, + drama: Drama, + music: Music2, +} + +const genreBarColors = [ + 'bg-[#7C3AED]', + 'bg-[#8B5CF6]', + 'bg-[#9F6FFB]', + 'bg-[#A78BFA]', + 'bg-[#B49BFC]', + 'bg-[#C4ABFD]', + 'bg-[#D3BCFE]', + 'bg-[#DDD6FE]', + 'bg-[#E8DFFE]', + 'bg-[#F0EAFF]', +] + +type PublicProfileData = TasteProfileResponse & { name: string } + +function possessive(name: string): string { + return name.endsWith('s') ? `${name}'` : `${name}'s` +} + +export default function PublicProfile() { + const { userId, token } = useParams<{ userId: string; token: string }>() + const [profile, setProfile] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + if (!userId || !token) return + setLoading(true) + getPublicProfile(userId, token) + .then(setProfile) + .catch(() => setError('This profile link is invalid or has expired.')) + .finally(() => setLoading(false)) + }, [userId, token]) + + if (loading) { + return ( +
+
+
+ ) + } + + if (error || !profile) { + return ( +
+
+

Profile Not Found

+

{error || 'This taste profile could not be found.'}

+ + Discover Your Taste on Vynl + +
+
+ ) + } + + const PersonalityIcon = personalityIcons[profile.personality.icon] || Music2 + const maxGenrePercent = profile.genre_breakdown.length > 0 + ? Math.max(...profile.genre_breakdown.map((g) => g.percentage)) + : 100 + + return ( +
+ {/* Header */} +
+ +

vynl

+ +
+ + {/* Content */} +
+ {/* Title */} +
+

+ + {possessive(profile.name)} Music DNA +

+

+ Built from {profile.track_count} tracks across {profile.playlist_count} playlists +

+
+ + {/* Listening Personality */} +
+
+
+ +
+
+

+ Listening Personality +

+

{profile.personality.label}

+

+ {profile.personality.description} +

+
+
+
+ + {/* Genre DNA */} + {profile.genre_breakdown.length > 0 && ( +
+

Genre DNA

+
+ {profile.genre_breakdown.map((genre, i) => ( +
+ + {genre.name} + +
+
+ + {genre.percentage}% + +
+
+
+ ))} +
+
+ )} + + {/* Audio Features */} +
+

Audio Features

+
+ + + + +
+ + Avg Tempo + +
+ {profile.audio_features.avg_tempo} + BPM +
+
+
+
+ + {/* Top Artists */} + {profile.top_artists.length > 0 && ( +
+

Artists That Define Them

+
+ {profile.top_artists.map((artist) => ( +
+
+ +
+
+

{artist.name}

+

+ {artist.track_count} track{artist.track_count !== 1 ? 's' : ''} + {artist.genre ? ` · ${artist.genre}` : ''} +

+
+
+ ))} +
+
+ )} + + {/* CTA */} +
+

+ Want to discover your own music DNA? +

+ + Discover Your Taste on Vynl + +
+
+
+ ) +} + +function PublicAudioMeter({ label, value }: { label: string; value: number }) { + return ( +
+ + {label} + +
+
0 ? '1.5rem' : '0' }} + > + {value > 10 && ( + + {value}% + + )} +
+
+ {value <= 10 && ( + {value}% + )} +
+ ) +} diff --git a/frontend/src/pages/TasteProfilePage.tsx b/frontend/src/pages/TasteProfilePage.tsx index ee38bca..1df38f8 100644 --- a/frontend/src/pages/TasteProfilePage.tsx +++ b/frontend/src/pages/TasteProfilePage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' -import { Loader2, Fingerprint, Zap, Music2, CloudSun, Heart, Layers, Globe, Drama, User } from 'lucide-react' -import { getTasteProfile, type TasteProfileResponse } from '../lib/api' +import { Loader2, Fingerprint, Zap, Music2, CloudSun, Heart, Layers, Globe, Drama, User, Share2, Check, Copy } from 'lucide-react' +import { getTasteProfile, getProfileShareLink, type TasteProfileResponse } from '../lib/api' const personalityIcons: Record = { zap: Zap, @@ -28,6 +28,8 @@ const genreBarColors = [ export default function TasteProfilePage() { const [profile, setProfile] = useState(null) const [loading, setLoading] = useState(true) + const [copied, setCopied] = useState(false) + const [sharing, setSharing] = useState(false) useEffect(() => { getTasteProfile() @@ -60,14 +62,45 @@ export default function TasteProfilePage() { return (
{/* Header */} -
-

- - My Taste DNA -

-

- Your musical identity, decoded from {profile.track_count} tracks across {profile.playlist_count} playlists -

+
+
+

+ + My Taste DNA +

+

+ Your musical identity, decoded from {profile.track_count} tracks across {profile.playlist_count} playlists +

+
+
{/* Listening Personality */}