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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user