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
This commit is contained in:
root
2026-03-31 00:21:58 -05:00
parent 789de25c1a
commit 1eea237c08
17 changed files with 898 additions and 113 deletions

View File

@@ -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