From 1eea237c08b62d7b34636d55f3f42dacb01b5565 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 31 Mar 2026 00:21:58 -0500 Subject: [PATCH] Add discovery modes, personalization controls, taste profile page, updated pricing - Discovery modes: Sonic Twin, Era Bridge, Deep Cuts, Rising Artists - Discovery dial (Safe to Adventurous slider) - Block genres/moods exclusion - Thumbs down/dislike on recommendations - My Taste page with Genre DNA breakdown, audio feature meters, listening personality - Updated pricing: Free (5/week), Premium ($6.99/mo), Family ($12.99/mo coming soon) - Weekly rate limiting instead of daily - Alembic migration for new fields --- .../003_add_dislike_and_preferences.py | 28 ++ backend/app/api/endpoints/profile.py | 200 +++++++++++++ backend/app/api/endpoints/recommendations.py | 23 +- backend/app/core/config.py | 2 +- backend/app/main.py | 3 +- backend/app/models/recommendation.py | 1 + backend/app/models/user.py | 4 + backend/app/schemas/recommendation.py | 6 +- backend/app/services/recommender.py | 68 ++++- frontend/src/App.tsx | 11 + frontend/src/components/Layout.tsx | 3 +- .../src/components/RecommendationCard.tsx | 24 +- frontend/src/lib/api.ts | 47 ++- frontend/src/pages/Billing.tsx | 278 ++++++++++++------ frontend/src/pages/Dashboard.tsx | 4 +- frontend/src/pages/Discover.tsx | 112 ++++++- frontend/src/pages/TasteProfilePage.tsx | 197 +++++++++++++ 17 files changed, 898 insertions(+), 113 deletions(-) create mode 100644 backend/alembic/versions/003_add_dislike_and_preferences.py create mode 100644 backend/app/api/endpoints/profile.py create mode 100644 frontend/src/pages/TasteProfilePage.tsx diff --git a/backend/alembic/versions/003_add_dislike_and_preferences.py b/backend/alembic/versions/003_add_dislike_and_preferences.py new file mode 100644 index 0000000..51cd72a --- /dev/null +++ b/backend/alembic/versions/003_add_dislike_and_preferences.py @@ -0,0 +1,28 @@ +"""Add dislike and personalization preferences + +Revision ID: 003 +Revises: 002 +Create Date: 2026-03-30 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "003" +down_revision: Union[str, None] = "002" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("recommendations", sa.Column("disliked", sa.Boolean(), default=False, nullable=False, server_default="false")) + op.add_column("users", sa.Column("blocked_genres", sa.Text(), nullable=True)) + op.add_column("users", sa.Column("adventurousness", sa.Integer(), default=3, nullable=False, server_default="3")) + + +def downgrade() -> None: + op.drop_column("recommendations", "disliked") + op.drop_column("users", "blocked_genres") + op.drop_column("users", "adventurousness") diff --git a/backend/app/api/endpoints/profile.py b/backend/app/api/endpoints/profile.py new file mode 100644 index 0000000..ed4a81d --- /dev/null +++ b/backend/app/api/endpoints/profile.py @@ -0,0 +1,200 @@ +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.security import get_current_user +from app.models.user import User +from app.models.playlist import Playlist +from app.models.track import Track + +router = APIRouter(prefix="/profile", tags=["profile"]) + + +def _determine_personality( + genre_count: int, + avg_energy: float, + avg_valence: float, + avg_acousticness: float, + energy_variance: float, + valence_variance: float, +) -> dict: + """Assign a listening personality based on taste data.""" + + # High variance in energy/valence = Mood Listener + if energy_variance > 0.06 and valence_variance > 0.06: + return { + "label": "Mood Listener", + "description": "Your music shifts with your emotions. You have playlists for every feeling and aren't afraid to go from euphoric highs to contemplative lows.", + "icon": "drama", + } + + # Many different genres = Genre Explorer + if genre_count >= 8: + return { + "label": "Genre Explorer", + "description": "You refuse to be put in a box. Your library is a world tour of sounds, spanning genres most listeners never discover.", + "icon": "globe", + } + + # High energy = Energy Seeker + if avg_energy > 0.7: + return { + "label": "Energy Seeker", + "description": "You crave intensity. Whether it's driving beats, soaring guitars, or thundering bass, your music keeps the adrenaline flowing.", + "icon": "zap", + } + + # Low energy + high acousticness = Chill Master + if avg_energy < 0.4 and avg_acousticness > 0.5: + return { + "label": "Chill Master", + "description": "You've mastered the art of the vibe. Acoustic textures and mellow grooves define your sonic world — your playlists are a warm blanket.", + "icon": "cloud", + } + + # Very consistent taste, low variance = Comfort Listener + if energy_variance < 0.03 and valence_variance < 0.03: + return { + "label": "Comfort Listener", + "description": "You know exactly what you like and you lean into it. Your taste is refined, consistent, and deeply personal.", + "icon": "heart", + } + + # Default: Catalog Diver + return { + "label": "Catalog Diver", + "description": "You dig deeper than the singles. Album tracks, B-sides, and deep cuts are your territory — you appreciate the full artistic vision.", + "icon": "layers", + } + + +@router.get("/taste") +async def get_taste_profile( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Aggregate all user playlists/tracks into a full taste profile.""" + 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] + + # Find a representative genre for each top artist + 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), + } diff --git a/backend/app/api/endpoints/recommendations.py b/backend/app/api/endpoints/recommendations.py index 2470cc9..6a30c88 100644 --- a/backend/app/api/endpoints/recommendations.py +++ b/backend/app/api/endpoints/recommendations.py @@ -22,15 +22,16 @@ async def generate( raise HTTPException(status_code=400, detail="Provide a playlist_id or query") recs, remaining = await generate_recommendations( - db, user, playlist_id=data.playlist_id, query=data.query, bandcamp_mode=data.bandcamp_mode + db, user, playlist_id=data.playlist_id, query=data.query, bandcamp_mode=data.bandcamp_mode, + mode=data.mode, adventurousness=data.adventurousness, exclude=data.exclude, ) if not recs and remaining == 0: - raise HTTPException(status_code=429, detail="Daily recommendation limit reached. Upgrade to Pro for unlimited.") + raise HTTPException(status_code=429, detail="Weekly recommendation limit reached. Upgrade to Premium for unlimited.") return RecommendationResponse( recommendations=[RecommendationItem.model_validate(r) for r in recs], - remaining_today=remaining, + remaining_this_week=remaining, ) @@ -75,3 +76,19 @@ async def save_recommendation( raise HTTPException(status_code=404, detail="Recommendation not found") rec.saved = not rec.saved return {"saved": rec.saved} + + +@router.post("/{rec_id}/dislike") +async def dislike_recommendation( + rec_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + 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") + rec.disliked = not rec.disliked + return {"disliked": rec.disliked} diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 22d2438..5a49e36 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -35,7 +35,7 @@ class Settings(BaseSettings): FRONTEND_URL: str = "http://localhost:5173" # Rate limits (free tier) - FREE_DAILY_RECOMMENDATIONS: int = 10 + FREE_WEEKLY_RECOMMENDATIONS: int = 5 FREE_MAX_PLAYLISTS: int = 1 model_config = {"env_file": ".env", "extra": "ignore"} diff --git a/backend/app/main.py b/backend/app/main.py index f5811fb..0b42f5f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.core.config import settings -from app.api.endpoints import auth, bandcamp, billing, lastfm, manual_import, playlists, recommendations, youtube_music +from app.api.endpoints import auth, bandcamp, billing, lastfm, manual_import, playlists, profile, recommendations, youtube_music app = FastAPI(title="Vynl API", version="1.0.0") @@ -22,6 +22,7 @@ app.include_router(youtube_music.router, prefix="/api") app.include_router(manual_import.router, prefix="/api") app.include_router(lastfm.router, prefix="/api") app.include_router(bandcamp.router, prefix="/api") +app.include_router(profile.router, prefix="/api") @app.get("/api/health") diff --git a/backend/app/models/recommendation.py b/backend/app/models/recommendation.py index 43f999c..030a2fa 100644 --- a/backend/app/models/recommendation.py +++ b/backend/app/models/recommendation.py @@ -29,6 +29,7 @@ class Recommendation(Base): # User interaction saved: Mapped[bool] = mapped_column(Boolean, default=False) + disliked: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index d7086b9..e08c12e 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -22,6 +22,10 @@ class User(Base): stripe_customer_id: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True) stripe_subscription_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + # Personalization + blocked_genres: Mapped[str | None] = mapped_column(Text, nullable=True) + adventurousness: Mapped[int] = mapped_column(default=3, server_default="3") + # Spotify OAuth spotify_id: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True) spotify_access_token: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/app/schemas/recommendation.py b/backend/app/schemas/recommendation.py index eff8cab..b223722 100644 --- a/backend/app/schemas/recommendation.py +++ b/backend/app/schemas/recommendation.py @@ -7,6 +7,9 @@ class RecommendationRequest(BaseModel): playlist_id: int | None = None query: str | None = None # Manual search/request bandcamp_mode: bool = False # Prioritize Bandcamp/indie artists + mode: str = "discover" # discover, sonic_twin, era_bridge, deep_cuts, rising + adventurousness: int = 3 # 1-5 + exclude: str | None = None # comma-separated genres to exclude class RecommendationItem(BaseModel): @@ -21,6 +24,7 @@ class RecommendationItem(BaseModel): reason: str score: float | None = None saved: bool = False + disliked: bool = False created_at: datetime model_config = {"from_attributes": True} @@ -28,7 +32,7 @@ class RecommendationItem(BaseModel): class RecommendationResponse(BaseModel): recommendations: list[RecommendationItem] - remaining_today: int | None = None # None = unlimited (pro) + remaining_this_week: int | None = None # None = unlimited (pro) class TasteProfile(BaseModel): diff --git a/backend/app/services/recommender.py b/backend/app/services/recommender.py index 77299d4..0d6511d 100644 --- a/backend/app/services/recommender.py +++ b/backend/app/services/recommender.py @@ -50,32 +50,54 @@ def build_taste_profile(tracks: list[Track]) -> dict: } -async def get_daily_rec_count(db: AsyncSession, user_id: int) -> int: - """Count recommendations generated today for rate limiting.""" - today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) +async def get_weekly_rec_count(db: AsyncSession, user_id: int) -> int: + """Count recommendations generated this week (since Monday) for rate limiting.""" + now = datetime.now(timezone.utc) + week_start = (now - timedelta(days=now.weekday())).replace(hour=0, minute=0, second=0, microsecond=0) result = await db.execute( select(func.count(Recommendation.id)).where( Recommendation.user_id == user_id, - Recommendation.created_at >= today_start, + Recommendation.created_at >= week_start, ) ) return result.scalar() or 0 +MODE_PROMPTS = { + "discover": "Find music they'll love. Mix well-known and underground artists.", + "sonic_twin": "Find underground or lesser-known artists who sound nearly identical to their favorites. Focus on artists under 100K monthly listeners who share the same sonic qualities — similar vocal style, production approach, tempo, and energy.", + "era_bridge": "Suggest classic artists from earlier eras who directly inspired their current favorites. Trace musical lineage — if they love Tame Impala, suggest the 70s psych rock that influenced him. Bridge eras.", + "deep_cuts": "Find B-sides, album tracks, rarities, and lesser-known songs from artists already in their library. Focus on tracks they probably haven't heard even from artists they already know.", + "rising": "Find artists with under 50K monthly listeners who match their taste. Focus on brand new, up-and-coming artists who haven't broken through yet. Think artists who just released their debut album or EP.", +} + + +def build_adventurousness_prompt(level: int) -> str: + if level <= 2: + return "Stick very close to their existing taste. Recommend artists who are very similar to what they already listen to." + elif level == 3: + return "Balance familiar and new. Mix artists similar to their taste with some that push boundaries." + else: + return "Be adventurous. Recommend artists that are different from their usual taste but share underlying qualities they'd appreciate. Push boundaries." + + async def generate_recommendations( db: AsyncSession, user: User, playlist_id: int | None = None, query: str | None = None, bandcamp_mode: bool = False, + mode: str = "discover", + adventurousness: int = 3, + exclude: str | None = None, ) -> tuple[list[Recommendation], int | None]: """Generate AI music recommendations using Claude.""" # Rate limit check for free users remaining = None if not user.is_pro: - used_today = await get_daily_rec_count(db, user.id) - remaining = max(0, settings.FREE_DAILY_RECOMMENDATIONS - used_today) + used_this_week = await get_weekly_rec_count(db, user.id) + remaining = max(0, settings.FREE_WEEKLY_RECOMMENDATIONS - used_this_week) if remaining <= 0: return [], 0 @@ -111,6 +133,15 @@ async def generate_recommendations( profile = build_taste_profile(all_tracks) taste_context = f"Taste profile from {len(all_tracks)} tracks:\n{json.dumps(profile, indent=2)}" + # Load disliked artists to exclude + disliked_result = await db.execute( + select(Recommendation.artist).where( + Recommendation.user_id == user.id, + Recommendation.disliked == True, + ) + ) + disliked_artists = list({a for a in disliked_result.scalars().all()}) + # Build prompt user_request = query or "Find me music I'll love based on my taste profile. Prioritize lesser-known artists and hidden gems." @@ -119,14 +150,39 @@ async def generate_recommendations( else: focus_instruction = "Focus on discovery - prioritize lesser-known artists, deep cuts, and hidden gems over obvious popular choices." + # Mode-specific instruction + mode_instruction = MODE_PROMPTS.get(mode, MODE_PROMPTS["discover"]) + + # Adventurousness instruction + adventurousness_instruction = build_adventurousness_prompt(adventurousness) + + # Exclude genres instruction + exclude_instruction = "" + combined_exclude = exclude or "" + if user.blocked_genres: + combined_exclude = f"{user.blocked_genres}, {combined_exclude}" if combined_exclude else user.blocked_genres + if combined_exclude.strip(): + exclude_instruction = f"\nDo NOT recommend anything in these genres/moods: {combined_exclude}" + + # Disliked artists exclusion + disliked_instruction = "" + if disliked_artists: + disliked_instruction = f"\nDo NOT recommend anything by these artists (user disliked them): {', '.join(disliked_artists[:30])}" + prompt = f"""You are Vynl, an AI music discovery assistant. You help people discover new music they'll love. {taste_context} User request: {user_request} +Discovery mode: {mode_instruction} + +{adventurousness_instruction} + Already in their library (do NOT recommend these): {', '.join(list(existing_tracks)[:50]) if existing_tracks else 'None provided'} +{disliked_instruction} +{exclude_instruction} Respond with exactly {"15" if bandcamp_mode else "5"} music recommendations as a JSON array. Each item should have: - "title": song title diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 57712a4..b03e2f2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import PlaylistDetail from './pages/PlaylistDetail' import Discover from './pages/Discover' import Recommendations from './pages/Recommendations' import Billing from './pages/Billing' +import TasteProfilePage from './pages/TasteProfilePage' function RootRedirect() { const { user, loading } = useAuth() @@ -43,6 +44,16 @@ function AppRoutes() { } /> + + + + + + } + /> void + onDislike?: (id: string) => void saving?: boolean + disliking?: boolean } -export default function RecommendationCard({ recommendation, onToggleSave, saving }: Props) { +export default function RecommendationCard({ recommendation, onToggleSave, onDislike, saving, disliking }: Props) { return (
@@ -60,6 +62,24 @@ export default function RecommendationCard({ recommendation, onToggleSave, savin /> + {onDislike && ( + + )} + {recommendation.bandcamp_url && ( api.post('/spotify/import', { playlist_id: playlistId }).then((r) => r.data) // Recommendations -export const generateRecommendations = (playlistId?: string, query?: string, bandcampMode?: boolean) => +export const generateRecommendations = ( + playlistId?: string, + query?: string, + bandcampMode?: boolean, + mode?: string, + adventurousness?: number, + exclude?: string, +) => api.post('/recommendations/generate', { playlist_id: playlistId, query, bandcamp_mode: bandcampMode || false, + mode: mode || 'discover', + adventurousness: adventurousness ?? 3, + exclude: exclude || undefined, }).then((r) => r.data) export const getRecommendationHistory = () => @@ -158,6 +194,9 @@ export const getSavedRecommendations = () => export const toggleSaveRecommendation = (id: string) => api.post<{ saved: boolean }>(`/recommendations/${id}/toggle-save`).then((r) => r.data) +export const dislikeRecommendation = (id: string) => + api.post<{ disliked: boolean }>(`/recommendations/${id}/dislike`).then((r) => r.data) + // YouTube Music Import export interface YouTubeTrackResult { title: string @@ -239,4 +278,8 @@ export async function getBandcampEmbed(url: string): Promise { return data } +// Taste Profile +export const getTasteProfile = () => + api.get('/profile/taste').then((r) => r.data) + export default api diff --git a/frontend/src/pages/Billing.tsx b/frontend/src/pages/Billing.tsx index b93e22c..a0272ac 100644 --- a/frontend/src/pages/Billing.tsx +++ b/frontend/src/pages/Billing.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { useSearchParams } from 'react-router-dom' -import { Crown, Check, Loader2, ExternalLink, Sparkles, Music, Infinity, Download } from 'lucide-react' +import { Crown, Check, Loader2, ExternalLink, Sparkles, Music, Infinity, Download, Users, Fingerprint, X } from 'lucide-react' import { useAuth } from '../lib/auth' import { createCheckout, createBillingPortal, getBillingStatus } from '../lib/api' @@ -10,11 +10,36 @@ interface BillingInfo { current_period_end: number | null } -const proFeatures = [ - { icon: Infinity, text: 'Unlimited recommendations per day' }, - { icon: Music, text: 'Unlimited playlist imports' }, - { icon: Sparkles, text: 'Advanced taste analysis' }, - { icon: Download, text: 'Export playlists to any platform' }, +interface TierFeature { + text: string + included: boolean +} + +const freeTierFeatures: TierFeature[] = [ + { text: '1 platform sync', included: true }, + { text: '5 discoveries per week', included: true }, + { text: 'Basic taste profile', included: true }, + { text: 'All platforms', included: false }, + { text: 'Unlimited discovery', included: false }, + { text: 'Full AI insights', included: false }, + { text: 'Export playlists', included: false }, +] + +const premiumTierFeatures: TierFeature[] = [ + { text: 'All platform syncs', included: true }, + { text: 'Unlimited discovery', included: true }, + { text: 'Full taste DNA profile', included: true }, + { text: 'Full AI insights & explanations', included: true }, + { text: 'Export to any platform', included: true }, + { text: 'All discovery modes', included: true }, + { text: 'Priority recommendations', included: true }, +] + +const familyTierFeatures: TierFeature[] = [ + { text: 'Everything in Premium', included: true }, + { text: 'Up to 5 profiles', included: true }, + { text: 'Family taste overlap feature', included: true }, + { text: 'Shared discovery feed', included: true }, ] export default function Billing() { @@ -72,15 +97,15 @@ export default function Billing() { const isPro = billing?.is_pro || false return ( -
+
-

Billing

-

Manage your subscription

+

Plans & Pricing

+

Choose the plan that fits your music discovery journey

{success && (
-

Welcome to Vynl Pro! Your subscription is now active.

+

Welcome to Vynl Premium! Your subscription is now active.

)} @@ -90,90 +115,165 @@ export default function Billing() {
)} - {/* Current Plan */} -
-
-
-
-
- -
-
-

- {isPro ? 'Vynl Pro' : 'Free Plan'} -

-

- {isPro ? '$4.99/month' : '10 recommendations/day, 1 playlist'} -

-
+ {/* Active subscription banner */} + {isPro && billing?.subscription_status && ( +
+
+
+
- {isPro && billing?.subscription_status && ( - - {billing.subscription_status === 'active' ? 'Active' : billing.subscription_status} - +
+

Vynl Premium Active

+ {billing.current_period_end && ( +

+ Next billing: {new Date(billing.current_period_end * 1000).toLocaleDateString()} +

+ )} +
+
+ +
+ )} + + {/* Pricing Cards */} +
+ {/* Free Tier */} +
+
+
+ +

Free

+
+
+ $0 + /month +
+

+ Get started with basic music discovery +

+
+
+
    + {freeTierFeatures.map((f) => ( +
  • + {f.included ? ( + + ) : ( + + )} + + {f.text} + +
  • + ))} +
+
+
+
+ {isPro ? 'Previous plan' : 'Current plan'} +
+
+
+ + {/* Premium Tier — Recommended */} +
+
+ Recommended +
+
+
+ +

Premium

+
+
+ $6.99 + /month +
+

+ Unlock the full power of AI music discovery +

+
+
+
    + {premiumTierFeatures.map((f) => ( +
  • + + {f.text} +
  • + ))} +
+
+
+ {isPro ? ( +
+ + Your current plan +
+ ) : ( + )}
- {isPro && billing?.current_period_end && ( -

- Next billing date: {new Date(billing.current_period_end * 1000).toLocaleDateString()} +

+ + {/* Family Tier — Coming Soon */} +
+
+
+ +

Family

+ + Coming Soon + +
+
+ $12.99 + /month +
+

+ Share discovery with up to 5 family members

- )} -
- - {/* Pro Features */} -
-

- {isPro ? 'Your Pro features' : 'Upgrade to Pro'} -

-
- {proFeatures.map((feature) => { - const Icon = feature.icon - return ( -
-
- -
- {feature.text} - {isPro && } -
- ) - })}
-
- - {/* Action */} -
- {isPro ? ( - - ) : ( - - )} +
+
    + {familyTierFeatures.map((f) => ( +
  • + + {f.text} +
  • + ))} +
+
+
+
+ Coming soon +
+
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 4692cab..bd2452b 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -119,10 +119,10 @@ export default function Dashboard() {

- {user?.daily_recommendations_remaining ?? 10} + {user?.daily_recommendations_remaining ?? 5}

- Recommendations left today + Discoveries left this week

diff --git a/frontend/src/pages/Discover.tsx b/frontend/src/pages/Discover.tsx index 235a09c..c1c66af 100644 --- a/frontend/src/pages/Discover.tsx +++ b/frontend/src/pages/Discover.tsx @@ -1,10 +1,26 @@ import { useState, useEffect } from 'react' import { useSearchParams } from 'react-router-dom' -import { Compass, Sparkles, Loader2, ListMusic, Search } from 'lucide-react' +import { Compass, Sparkles, Loader2, ListMusic, Search, Users, Clock, Disc3, TrendingUp } from 'lucide-react' import { useAuth } from '../lib/auth' -import { getPlaylists, generateRecommendations, toggleSaveRecommendation, type PlaylistResponse, type RecommendationItem } from '../lib/api' +import { getPlaylists, generateRecommendations, toggleSaveRecommendation, dislikeRecommendation, type PlaylistResponse, type RecommendationItem } from '../lib/api' import RecommendationCard from '../components/RecommendationCard' +const DISCOVERY_MODES = [ + { id: 'discover', label: 'Discover', icon: Compass, description: 'General recommendations' }, + { id: 'sonic_twin', label: 'Sonic Twin', icon: Users, description: 'Underground artists who sound like your favorites' }, + { id: 'era_bridge', label: 'Era Bridge', icon: Clock, description: 'Classic artists who inspired your favorites' }, + { id: 'deep_cuts', label: 'Deep Cuts', icon: Disc3, description: 'B-sides and rarities from artists you know' }, + { id: 'rising', label: 'Rising', icon: TrendingUp, description: 'Under 50K listeners who fit your profile' }, +] as const + +const ADVENTUROUSNESS_LABELS: Record = { + 1: 'Safe', + 2: 'Familiar', + 3: 'Balanced', + 4: 'Exploring', + 5: 'Adventurous', +} + export default function Discover() { const { user } = useAuth() const [searchParams] = useSearchParams() @@ -18,6 +34,10 @@ export default function Discover() { const [error, setError] = useState('') const [bandcampMode, setBandcampMode] = useState(false) const [savingIds, setSavingIds] = useState>(new Set()) + const [dislikingIds, setDislikingIds] = useState>(new Set()) + const [mode, setMode] = useState('discover') + const [adventurousness, setAdventurousness] = useState(3) + const [excludeGenres, setExcludeGenres] = useState('') useEffect(() => { const load = async () => { @@ -47,10 +67,13 @@ export default function Discover() { const response = await generateRecommendations( selectedPlaylist || undefined, query.trim() || undefined, - bandcampMode + bandcampMode, + mode, + adventurousness, + excludeGenres.trim() || undefined, ) setResults(response.recommendations) - setRemaining(response.remaining_today) + setRemaining(response.remaining_this_week) } catch (err: any) { setError( err.response?.data?.detail || 'Failed to generate recommendations. Please try again.' @@ -78,6 +101,24 @@ export default function Discover() { } } + const handleDislike = async (id: string) => { + setDislikingIds((prev) => new Set(prev).add(id)) + try { + const { disliked } = await dislikeRecommendation(id) + setResults((prev) => + prev.map((r) => (r.id === id ? { ...r, disliked } : r)) + ) + } catch { + // silent + } finally { + setDislikingIds((prev) => { + const next = new Set(prev) + next.delete(id) + return next + }) + } + } + if (loading) { return (
@@ -98,6 +139,25 @@ export default function Discover() {

+ {/* Discovery Modes */} +
+ {DISCOVERY_MODES.map(({ id, label, icon: Icon, description }) => ( + + ))} +
+ {/* Discovery Form */}
{/* Playlist Selector */} @@ -135,6 +195,46 @@ export default function Discover() { />
+ {/* Discovery Dial */} +
+ +
+ setAdventurousness(Number(e.target.value))} + className="w-full h-2 bg-purple-100 rounded-full appearance-none cursor-pointer accent-purple" + /> +
+ Safe + Balanced + Adventurous +
+
+
+ + {/* Block Genres */} +
+ + setExcludeGenres(e.target.value)} + placeholder="country, sad songs, metal" + className="w-full px-4 py-3 bg-cream/50 border border-purple-100 rounded-xl text-charcoal placeholder:text-charcoal-muted/40 focus:outline-none focus:ring-2 focus:ring-purple/30 focus:border-purple transition-colors text-sm" + /> +
+ {/* Bandcamp Mode Toggle */}
diff --git a/frontend/src/pages/TasteProfilePage.tsx b/frontend/src/pages/TasteProfilePage.tsx new file mode 100644 index 0000000..9d2297b --- /dev/null +++ b/frontend/src/pages/TasteProfilePage.tsx @@ -0,0 +1,197 @@ +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' + +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]', +] + +export default function TasteProfilePage() { + const [profile, setProfile] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + getTasteProfile() + .then(setProfile) + .catch(() => setProfile(null)) + .finally(() => setLoading(false)) + }, []) + + if (loading) { + return ( +
+ +
+ ) + } + + if (!profile) { + return ( +
+

Could not load your taste profile.

+
+ ) + } + + 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 */} +
+

+ + My Taste DNA +

+

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

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

+ Your Listening Personality +

+

{profile.personality.label}

+

+ {profile.personality.description} +

+
+
+
+ + {/* Genre DNA */} +
+

Genre DNA

+ {profile.genre_breakdown.length === 0 ? ( +

+ No genre data yet. Import playlists with Spotify to see your genre breakdown. +

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

Audio Features

+
+ + + + +
+ + Avg Tempo + +
+ {profile.audio_features.avg_tempo} + BPM +
+
+
+
+ + {/* Top Artists */} +
+

Artists That Define You

+ {profile.top_artists.length === 0 ? ( +

+ Import playlists to see your defining artists. +

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

{artist.name}

+

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

+
+
+ ))} +
+ )} +
+
+ ) +} + +function AudioMeter({ label, value }: { label: string; value: number }) { + return ( +
+ + {label} + +
+
0 ? '1.5rem' : '0' }} + > + {value > 10 && ( + + {value}% + + )} +
+
+ {value <= 10 && ( + {value}% + )} +
+ ) +}