From 34b3667e31deaf43ccf4255092eddebe30a978fb Mon Sep 17 00:00:00 2001 From: lebuckman Date: Wed, 1 Apr 2026 16:43:38 -0700 Subject: [PATCH 1/9] feat(spotify): add data layer with caching - add cache-aside utility with typed CacheKey enum and TTL constants - add Spotify API Client with wrappers for fetching playlists, top artists, and top tracks - implement /api/spotify routes that utilize the Spotify API Client and cache results - handle Spotify 401 and 429 errors explicitly in API routes - transform Spotify responses into simplified formats before caching and returning to clients Note: need to update deprecated attributes or endpoints - audio-features - top artists genres and popularity --- src/app/api/spotify/playlists/route.ts | 60 +++++++++++++++ src/app/api/spotify/top-artists/route.ts | 86 +++++++++++++++++++++ src/app/api/spotify/top-tracks/route.ts | 90 ++++++++++++++++++++++ src/lib/spotify/cache.ts | 72 ++++++++++++++++++ src/lib/spotify/client.ts | 97 ++++++++++++++++++++++++ 5 files changed, 405 insertions(+) create mode 100644 src/app/api/spotify/playlists/route.ts create mode 100644 src/app/api/spotify/top-artists/route.ts create mode 100644 src/app/api/spotify/top-tracks/route.ts create mode 100644 src/lib/spotify/cache.ts create mode 100644 src/lib/spotify/client.ts diff --git a/src/app/api/spotify/playlists/route.ts b/src/app/api/spotify/playlists/route.ts new file mode 100644 index 0000000..d3bfb91 --- /dev/null +++ b/src/app/api/spotify/playlists/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from "next/server"; +import { getSession } from "@/lib/session"; +import { withValidToken } from "@/lib/spotify/token"; +import { fetchPlaylists } from "@/lib/spotify/client"; +import { getCached, setCached, CACHE_TTL } from "@/lib/spotify/cache"; +import type { SpotifyPlaylist } from "@/types"; + +export interface PlaylistsResponse { + playlists: { + id: string; + name: string; + description: string; + imageUrl: string; + trackCount: number; + spotifyUrl: string; + }[]; + cached: boolean; +} + +export async function GET() { + const session = await getSession(); + + if (!session.userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + // Check cache first + const cached = await getCached(session.userId, "playlists"); + + if (cached) { + return NextResponse.json({ playlists: cached, cached: true } satisfies PlaylistsResponse); + } + + // Cache miss — get a valid token and fetch from Spotify + const accessToken = await withValidToken(session.userId); + const response = await fetchPlaylists(accessToken); + + // Transform — only keep fields that are actually needed + const playlists = response.items.map((playlist: SpotifyPlaylist) => ({ + id: playlist.id, + name: playlist.name, + description: playlist.description ?? "", + imageUrl: playlist.images[0]?.url ?? "", + trackCount: playlist.tracks?.total ?? 0, + spotifyUrl: playlist.external_urls.spotify, + })); + + // Write to cache + await setCached(session.userId, "playlists", playlists, CACHE_TTL.PLAYLISTS); + + return NextResponse.json({ playlists, cached: false } satisfies PlaylistsResponse); + } catch (error) { + if (error instanceof Error && error.message === "SPOTIFY_UNAUTHORIZED") { + return NextResponse.json({ error: "Spotify session expired" }, { status: 401 }); + } + console.error("Playlists error:", error); + return NextResponse.json({ error: "Failed to fetch playlists" }, { status: 500 }); + } +} diff --git a/src/app/api/spotify/top-artists/route.ts b/src/app/api/spotify/top-artists/route.ts new file mode 100644 index 0000000..d9f98e1 --- /dev/null +++ b/src/app/api/spotify/top-artists/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSession } from "@/lib/session"; +import { withValidToken } from "@/lib/spotify/token"; +import { fetchTopArtists } from "@/lib/spotify/client"; +import { getCached, setCached, CACHE_TTL } from "@/lib/spotify/cache"; +import type { TimeRange, SpotifyArtist } from "@/types"; +import type { CacheKey } from "@/lib/spotify/cache"; + +export interface TopArtistsResponse { + artists: { + id: string; + name: string; + genres: string[]; + imageUrl: string; + popularity: number; + spotifyUrl: string; + }[]; + timeRange: TimeRange; + cached: boolean; +} + +export async function GET(request: NextRequest) { + const session = await getSession(); + + if (!session.userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const timeRange = (searchParams.get("range") ?? "short_term") as TimeRange; + + // Validate time range param + const validRanges: TimeRange[] = ["short_term", "medium_term", "long_term"]; + if (!validRanges.includes(timeRange)) { + return NextResponse.json({ error: "Invalid time range" }, { status: 400 }); + } + + const cacheKey = `top_artists:${timeRange}` as CacheKey; + + try { + // Check cache first + const cached = await getCached(session.userId, cacheKey); + + if (cached) { + return NextResponse.json({ + artists: cached, + timeRange, + cached: true, + } satisfies TopArtistsResponse); + } + + // Cache miss — get a valid token and fetch from Spotify + const accessToken = await withValidToken(session.userId); + const response = await fetchTopArtists(accessToken, timeRange); + + // Transform — only keep fields that are actually needed + const artists = response.items.map((artist: SpotifyArtist) => ({ + id: artist.id, + name: artist.name, + genres: artist.genres, + imageUrl: artist.images[0]?.url ?? "", + popularity: artist.popularity, + spotifyUrl: artist.external_urls.spotify, + })); + + // Write to cache + await setCached(session.userId, cacheKey, artists, CACHE_TTL.TOP_ARTISTS); + + return NextResponse.json({ + artists, + timeRange, + cached: false, + } satisfies TopArtistsResponse); + } catch (error) { + if (error instanceof Error) { + if (error.message === "SPOTIFY_UNAUTHORIZED") { + return NextResponse.json({ error: "Spotify session expired" }, { status: 401 }); + } + if (error.message.startsWith("SPOTIFY_RATE_LIMITED")) { + return NextResponse.json({ error: "Rate limited by Spotify" }, { status: 429 }); + } + } + console.error("Top artists error:", error); + return NextResponse.json({ error: "Failed to fetch top artists" }, { status: 500 }); + } +} diff --git a/src/app/api/spotify/top-tracks/route.ts b/src/app/api/spotify/top-tracks/route.ts new file mode 100644 index 0000000..24e3d96 --- /dev/null +++ b/src/app/api/spotify/top-tracks/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSession } from "@/lib/session"; +import { withValidToken } from "@/lib/spotify/token"; +import { fetchTopTracks } from "@/lib/spotify/client"; +import { getCached, setCached, CACHE_TTL } from "@/lib/spotify/cache"; +import type { TimeRange, SpotifyTrack } from "@/types"; +import type { CacheKey } from "@/lib/spotify/cache"; + +export interface TopTracksResponse { + tracks: { + id: string; + name: string; + artists: { id: string; name: string }[]; + album: { id: string; name: string; imageUrl: string }; + durationMs: number; + spotifyUrl: string; + }[]; + timeRange: TimeRange; + cached: boolean; +} + +export async function GET(request: NextRequest) { + const session = await getSession(); + + if (!session.userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const timeRange = (searchParams.get("range") ?? "short_term") as TimeRange; + + // Validate time range param + const validRanges: TimeRange[] = ["short_term", "medium_term", "long_term"]; + if (!validRanges.includes(timeRange)) { + return NextResponse.json({ error: "Invalid time range" }, { status: 400 }); + } + + const cacheKey = `top_tracks:${timeRange}` as CacheKey; + + try { + // Check cache first + const cached = await getCached(session.userId, cacheKey); + + if (cached) { + return NextResponse.json({ + tracks: cached, + timeRange, + cached: true, + } satisfies TopTracksResponse); + } + + // Cache miss — get a valid token and fetch from Spotify + const accessToken = await withValidToken(session.userId); + const response = await fetchTopTracks(accessToken, timeRange); + + // Transform — only keep fields that are actually needed + const tracks = response.items.map((track: SpotifyTrack) => ({ + id: track.id, + name: track.name, + artists: track.artists.map((a) => ({ id: a.id, name: a.name })), + album: { + id: track.album.id, + name: track.album.name, + imageUrl: track.album.images[0]?.url ?? "", + }, + durationMs: track.duration_ms, + spotifyUrl: track.external_urls.spotify, + })); + + // Write to cache + await setCached(session.userId, cacheKey, tracks, CACHE_TTL.TOP_TRACKS); + + return NextResponse.json({ + tracks, + timeRange, + cached: false, + } satisfies TopTracksResponse); + } catch (error) { + if (error instanceof Error) { + if (error.message === "SPOTIFY_UNAUTHORIZED") { + return NextResponse.json({ error: "Spotify session expired" }, { status: 401 }); + } + if (error.message.startsWith("SPOTIFY_RATE_LIMITED")) { + return NextResponse.json({ error: "Rate limited by Spotify" }, { status: 429 }); + } + } + console.error("Top tracks error:", error); + return NextResponse.json({ error: "Failed to fetch top tracks" }, { status: 500 }); + } +} diff --git a/src/lib/spotify/cache.ts b/src/lib/spotify/cache.ts new file mode 100644 index 0000000..09cd64e --- /dev/null +++ b/src/lib/spotify/cache.ts @@ -0,0 +1,72 @@ +// Cache utilities for Spotify data. +// Wraps the spotify_cache table with typed read/write helpers. +// Lazy Loading: check cache on read, populate on miss. + +import { db } from "@/lib/db"; +import { spotifyCache } from "@/lib/db/schema"; +import { eq, and, gt } from "drizzle-orm"; + +export type CacheKey = + | "top_tracks:short_term" + | "top_tracks:medium_term" + | "top_tracks:long_term" + | "top_artists:short_term" + | "top_artists:medium_term" + | "top_artists:long_term" + | "audio_features" + | "playlists"; + +// TTL constants in milliseconds +export const CACHE_TTL = { + TOP_TRACKS: 60 * 60 * 1000, // 1 hour + TOP_ARTISTS: 60 * 60 * 1000, // 1 hour + AUDIO_FEATURES: 6 * 60 * 60 * 1000, // 6 hours + PLAYLISTS: 30 * 60 * 1000, // 30 minutes +} as const; + +/** + * Reads a cache entry for a user. Returns null on miss or expiry. + */ +export async function getCached(userId: string, cacheKey: CacheKey): Promise { + const entry = await db.query.spotifyCache.findFirst({ + where: and( + eq(spotifyCache.userId, userId), + eq(spotifyCache.cacheKey, cacheKey), + gt(spotifyCache.expiresAt, new Date()) + ), + }); + + if (!entry) return null; + return entry.data as T; +} + +/** + * Writes or overwrites a cache entry for a user. + */ +export async function setCached( + userId: string, + cacheKey: CacheKey, + data: T, + ttlMs: number +): Promise { + const now = new Date(); + const expiresAt = new Date(now.getTime() + ttlMs); + + await db + .insert(spotifyCache) + .values({ + userId, + cacheKey, + data: data as Record, + cachedAt: now, + expiresAt, + }) + .onConflictDoUpdate({ + target: [spotifyCache.userId, spotifyCache.cacheKey], + set: { + data: data as Record, + cachedAt: now, + expiresAt, + }, + }); +} diff --git a/src/lib/spotify/client.ts b/src/lib/spotify/client.ts new file mode 100644 index 0000000..4da521a --- /dev/null +++ b/src/lib/spotify/client.ts @@ -0,0 +1,97 @@ +// Wrapper around the Spotify Web API. +// All functions take an access token and return typed responses. +// Token management (refresh, encryption) is handled by withValidToken() +// before these functions are ever called. + +import type { + SpotifyTrack, + SpotifyArtist, + AudioFeatures, + SpotifyPlaylist, + TimeRange, +} from "@/types"; + +const SPOTIFY_API_BASE = "https://api.spotify.com/v1"; + +async function spotifyFetch(endpoint: string, accessToken: string): Promise { + const response = await fetch(`${SPOTIFY_API_BASE}${endpoint}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + // Tell Next.js to not cache this fetch — handled in the DB layer. + next: { revalidate: 0 }, + }); + + if (response.status === 401) { + throw new Error("SPOTIFY_UNAUTHORIZED"); + } + + if (response.status === 429) { + // Explicitly return the retry time in the error message for clearer + // debugging. If the header is missing, default to 1 second. + const retryAfter = response.headers.get("Retry-After"); + throw new Error(`SPOTIFY_RATE_LIMITED:${retryAfter ?? "1"}`); + } + + if (!response.ok) { + throw new Error(`Spotify API error: ${response.status} ${endpoint}`); + } + + return response.json() as Promise; +} + +// ── Top Tracks ────────────────────────────────────────────────────────────── + +interface SpotifyTopTracksResponse { + items: SpotifyTrack[]; + total: number; + limit: number; + offset: number; +} + +export async function fetchTopTracks(accessToken: string, timeRange: TimeRange, limit = 50) { + return spotifyFetch( + `/me/top/tracks?time_range=${timeRange}&limit=${limit}`, + accessToken + ); +} + +// ── Top Artists ────────────────────────────────────────────────────────────── + +interface SpotifyTopArtistsResponse { + items: SpotifyArtist[]; + total: number; + limit: number; + offset: number; +} + +export async function fetchTopArtists(accessToken: string, timeRange: TimeRange, limit = 50) { + return spotifyFetch( + `/me/top/artists?time_range=${timeRange}&limit=${limit}`, + accessToken + ); +} + +// ── Audio Features ─────────────────────────────────────────────────────────── + +interface SpotifyAudioFeaturesResponse { + audio_features: AudioFeatures[]; +} + +export async function fetchAudioFeatures(accessToken: string, trackIds: string[]) { + return spotifyFetch( + `/audio-features?ids=${trackIds.join(",")}`, + accessToken + ); +} + +// ── Playlists ──────────────────────────────────────────────────────────────── + +interface SpotifyPlaylistsResponse { + items: SpotifyPlaylist[]; + total: number; + limit: number; + offset: number; +} + +export async function fetchPlaylists(accessToken: string, limit = 20) { + return spotifyFetch(`/me/playlists?limit=${limit}`, accessToken); +} From 0b91f4acea0d289416e9e875069707f80817c0c0 Mon Sep 17 00:00:00 2001 From: lebuckman Date: Wed, 1 Apr 2026 20:28:35 -0700 Subject: [PATCH 2/9] feat(data): add Last.fm genre breakdown endpoint - Add Last.fm API client with artist.getTopTags integration - Add genre tag filtering, normalization, and within-artist weighting - Add /api/lastfm/genre-breakdown route with 7-day DB cache - Remove deprecated /api/spotify/audio-features route - Remove deprecated genres and popularity attributes from SpotifyArtist type - Fix SpotifyPlaylist tracks -> items field rename - Add genre_breakdown CacheKey with 7-day TTL - Add LASTFM_API_KEY to env configuration --- .env.example | 1 + src/app/api/lastfm/genre-breakdown/route.ts | 70 +++++++++++ src/app/api/spotify/playlists/route.ts | 2 +- src/app/api/spotify/top-artists/route.ts | 8 +- src/app/api/spotify/top-tracks/route.ts | 2 +- src/lib/lastfm/client.ts | 35 ++++++ src/lib/lastfm/genres.ts | 121 ++++++++++++++++++++ src/lib/spotify/cache.ts | 6 +- src/types/index.ts | 40 ++++--- 9 files changed, 256 insertions(+), 29 deletions(-) create mode 100644 src/app/api/lastfm/genre-breakdown/route.ts create mode 100644 src/lib/lastfm/client.ts create mode 100644 src/lib/lastfm/genres.ts diff --git a/.env.example b/.env.example index 0ac2049..6fe5abc 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ SPOTIFY_CLIENT_ID= SPOTIFY_CLIENT_SECRET= SPOTIFY_REDIRECT_URI=http://127.0.0.1:3000/api/auth/callback +LASTFM_API_KEY= SESSION_SECRET= TOKEN_ENCRYPTION_KEY= DATABASE_URL= diff --git a/src/app/api/lastfm/genre-breakdown/route.ts b/src/app/api/lastfm/genre-breakdown/route.ts new file mode 100644 index 0000000..fc43d4f --- /dev/null +++ b/src/app/api/lastfm/genre-breakdown/route.ts @@ -0,0 +1,70 @@ +// Derives a weighted genre breakdown for the user by: +// 1. Reading their cached top artists (depends on top_artists:short_term cache) +// 2. Fetching Last.fm tags for each artist name +// 3. Aggregating and weighting tags into a genre profile + +import { NextResponse } from "next/server"; +import { getSession } from "@/lib/session"; +import { getCached, setCached, CACHE_TTL } from "@/lib/spotify/cache"; +import { fetchArtistTopTags } from "@/lib/lastfm/client"; +import { aggregateGenres } from "@/lib/lastfm/genres"; +import type { GenreEntry, LastFmTag } from "@/types"; + +export interface GenreBreakdownResponse { + genres: GenreEntry[]; + cached: boolean; +} + +export async function GET() { + const session = await getSession(); + + if (!session.userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + // 1Check genre breakdown cache first (24hr TTL) + const cached = await getCached(session.userId, "genre_breakdown"); + if (cached) { + return NextResponse.json({ genres: cached, cached: true } satisfies GenreBreakdownResponse); + } + + // Read top artists from existing cache + // If top artists haven't been fetched yet, return an empty + // result and let the client retry after they load. + type CachedArtist = { id: string; name: string; imageUrl: string; spotifyUrl: string }; + const cachedArtists = await getCached(session.userId, "top_artists:short_term"); + + if (!cachedArtists || cachedArtists.length === 0) { + return NextResponse.json({ genres: [], cached: false } satisfies GenreBreakdownResponse); + } + + // Fetch Last.fm tags for each artist + const artistsToQuery = cachedArtists.slice(0, 20); + const artistTags: { artistRank: number; artistName: string; tags: LastFmTag[] }[] = []; + + for (let i = 0; i < artistsToQuery.length; i++) { + const artist = artistsToQuery[i]; + if (!artist) continue; + + const tags = await fetchArtistTopTags(artist.name); + artistTags.push({ artistRank: i + 1, artistName: artist.name, tags }); + + // Small delay to avoid rate limiting (Last.fm terms) + if (i < artistsToQuery.length - 1) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + + // Aggregate into weighted genre breakdown + const genres = aggregateGenres(artistTags); + + // Cache the result for 24 hours + await setCached(session.userId, "genre_breakdown", genres, CACHE_TTL.GENRE_BREAKDOWN); + + return NextResponse.json({ genres, cached: false } satisfies GenreBreakdownResponse); + } catch (error) { + console.error("Genre breakdown error:", error); + return NextResponse.json({ error: "Failed to fetch genre breakdown" }, { status: 500 }); + } +} diff --git a/src/app/api/spotify/playlists/route.ts b/src/app/api/spotify/playlists/route.ts index d3bfb91..8ecd230 100644 --- a/src/app/api/spotify/playlists/route.ts +++ b/src/app/api/spotify/playlists/route.ts @@ -42,7 +42,7 @@ export async function GET() { name: playlist.name, description: playlist.description ?? "", imageUrl: playlist.images[0]?.url ?? "", - trackCount: playlist.tracks?.total ?? 0, + trackCount: playlist.items?.total ?? 0, spotifyUrl: playlist.external_urls.spotify, })); diff --git a/src/app/api/spotify/top-artists/route.ts b/src/app/api/spotify/top-artists/route.ts index d9f98e1..3a6d796 100644 --- a/src/app/api/spotify/top-artists/route.ts +++ b/src/app/api/spotify/top-artists/route.ts @@ -1,18 +1,16 @@ -import { NextRequest, NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; import { getSession } from "@/lib/session"; import { withValidToken } from "@/lib/spotify/token"; import { fetchTopArtists } from "@/lib/spotify/client"; import { getCached, setCached, CACHE_TTL } from "@/lib/spotify/cache"; -import type { TimeRange, SpotifyArtist } from "@/types"; +import type { SpotifyArtist, TimeRange } from "@/types"; import type { CacheKey } from "@/lib/spotify/cache"; export interface TopArtistsResponse { artists: { id: string; name: string; - genres: string[]; imageUrl: string; - popularity: number; spotifyUrl: string; }[]; timeRange: TimeRange; @@ -57,9 +55,7 @@ export async function GET(request: NextRequest) { const artists = response.items.map((artist: SpotifyArtist) => ({ id: artist.id, name: artist.name, - genres: artist.genres, imageUrl: artist.images[0]?.url ?? "", - popularity: artist.popularity, spotifyUrl: artist.external_urls.spotify, })); diff --git a/src/app/api/spotify/top-tracks/route.ts b/src/app/api/spotify/top-tracks/route.ts index 24e3d96..0634c17 100644 --- a/src/app/api/spotify/top-tracks/route.ts +++ b/src/app/api/spotify/top-tracks/route.ts @@ -1,4 +1,4 @@ -import { NextRequest, NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; import { getSession } from "@/lib/session"; import { withValidToken } from "@/lib/spotify/token"; import { fetchTopTracks } from "@/lib/spotify/client"; diff --git a/src/lib/lastfm/client.ts b/src/lib/lastfm/client.ts new file mode 100644 index 0000000..c507df2 --- /dev/null +++ b/src/lib/lastfm/client.ts @@ -0,0 +1,35 @@ +// Wrapper around the Last.fm Web API. +// Used exclusively for artist genre tags. + +import type { LastFmTag, LastFmTagsResponse } from "@/types"; + +const LASTFM_BASE = "https://ws.audioscrobbler.com/2.0/"; + +export async function fetchArtistTopTags(artistName: string): Promise { + const params = new URLSearchParams({ + method: "artist.gettoptags", + artist: artistName, + api_key: process.env.LASTFM_API_KEY as string, + format: "json", + autocorrect: "1", // Last.fm will correct minor spelling differences + }); + + const response = await fetch(`${LASTFM_BASE}?${params.toString()}`, { + next: { revalidate: 0 }, + }); + + if (!response.ok) { + // Some artists aren't in Last.fm's database. + // Return empty array and let the caller handle it gracefully. + return []; + } + + const data = (await response.json()) as LastFmTagsResponse | { error: number; message: string }; + + // Last.fm returns a 200 with an error field for unknown artists + if ("error" in data) { + return []; + } + + return data.toptags.tag ?? []; +} diff --git a/src/lib/lastfm/genres.ts b/src/lib/lastfm/genres.ts new file mode 100644 index 0000000..6ebf38f --- /dev/null +++ b/src/lib/lastfm/genres.ts @@ -0,0 +1,121 @@ +// Genre tag processing for Last.fm artist tags. +// Remove common noise, then pass through to Claude +// for intelligent interpretation. + +import type { LastFmTag, GenreEntry } from "@/types"; + +// Non-exhaustive list of common non-genre tags +const NON_GENRE_BLOCKLIST = new Set([ + "seen live", + "favorites", + "favorite", + "love", + "like", + "my profile", + "under 2000 listeners", + "all", +]); + +// Minimum tag count threshold — filters out personal or one-off tags +// that haven't been applied by enough Last.fm users to be meaningful. +const MIN_TAG_COUNT = 5; + +// Normalize common variants of popular genres (non-exhaustive). +// Unmapped variants will be interpreted correctly by Claude downstream. +const TAG_NORMALIZATIONS: Record = { + kpop: "k-pop", + "korean pop": "k-pop", + "k pop": "k-pop", + jpop: "j-pop", + "japanese pop": "j-pop", + "j pop": "j-pop", + jrock: "j-rock", + "j rock": "j-rock", + cpop: "c-pop", + "chinese pop": "c-pop", + mandopop: "c-pop", + "thai pop": "thai", + "t-pop": "thai", + tpop: "thai", + thailand: "thai", + rnb: "r&b", + "rhythm and blues": "r&b", + hiphop: "hip hop", + "hip-hop": "hip hop", + "electronic music": "electronic", +}; + +function normalizeTag(tag: string): string { + const lower = tag.toLowerCase().trim(); + return TAG_NORMALIZATIONS[lower] ?? lower; +} + +function isValidTag(tag: string, count: number): boolean { + if (count < MIN_TAG_COUNT) return false; + const lower = tag.toLowerCase().trim(); + if (NON_GENRE_BLOCKLIST.has(lower)) return false; + if (lower.length < 3) return false; + if (/^\d{4}$/.test(lower)) return false; + return true; +} + +/** + * Aggregates Last.fm tags across multiple artists into a weighted genre breakdown. + * + * Weighting strategy: + * - Artist rank weight: rank #1 contributes more than rank #20, regardless + * of how famous the artist is on Last.fm + * - Within-artist normalization: tag counts are normalized relative to that + * artist's own highest tag — this removes Last.fm popularity bias so a + * small indie artist contributes equally to a major label act at the same rank + * - Artist name exclusion: dynamically filters out tags that match queried + * artist names + * + * Output is raw input for Claude, not a final UI-ready classification. + * Claude interprets the tags in context and surfaces meaningful patterns in natural language. + */ +export function aggregateGenres( + artistTags: { artistRank: number; artistName: string; tags: LastFmTag[] }[], + topN = 8 +): GenreEntry[] { + const genreWeights = new Map(); + + // Build a set of artist names to exclude dynamically + const artistNameSet = new Set(artistTags.map((a) => a.artistName.toLowerCase().trim())); + + for (const { artistRank, tags } of artistTags) { + const relevantTags = tags + .filter((t) => { + const normalized = normalizeTag(t.name); + if (artistNameSet.has(normalized)) return false; + if (artistNameSet.has(t.name.toLowerCase().trim())) return false; + return isValidTag(t.name, t.count); + }) + .slice(0, 5); // top 5 tags per artist is sufficient signal + + if (relevantTags.length === 0) continue; + + // Rank 1 = weight 50, rank 20 = weight 31, rank 50 = weight 1 + const artistWeight = Math.max(1, 51 - artistRank); + + // Normalize within this artist so tag relevance is relative to + // their own profile, not their absolute Last.fm popularity + const maxCount = relevantTags[0]?.count ?? 1; + + for (const tag of relevantTags) { + const normalized = normalizeTag(tag.name); + const normalizedTagScore = tag.count / maxCount; + const contribution = normalizedTagScore * artistWeight; + genreWeights.set(normalized, (genreWeights.get(normalized) ?? 0) + contribution); + } + } + + const sorted = [...genreWeights.entries()].sort((a, b) => b[1] - a[1]).slice(0, topN); + + const maxWeight = sorted[0]?.[1] ?? 1; + + return sorted.map(([genre, weight]) => ({ + genre, + weight: Math.round((weight / maxWeight) * 100), + })); +} diff --git a/src/lib/spotify/cache.ts b/src/lib/spotify/cache.ts index 09cd64e..f7ece9b 100644 --- a/src/lib/spotify/cache.ts +++ b/src/lib/spotify/cache.ts @@ -13,15 +13,15 @@ export type CacheKey = | "top_artists:short_term" | "top_artists:medium_term" | "top_artists:long_term" - | "audio_features" - | "playlists"; + | "playlists" + | "genre_breakdown"; // TTL constants in milliseconds export const CACHE_TTL = { TOP_TRACKS: 60 * 60 * 1000, // 1 hour TOP_ARTISTS: 60 * 60 * 1000, // 1 hour - AUDIO_FEATURES: 6 * 60 * 60 * 1000, // 6 hours PLAYLISTS: 30 * 60 * 1000, // 30 minutes + GENRE_BREAKDOWN: 7 * 24 * 60 * 60 * 1000, // 7 days } as const; /** diff --git a/src/types/index.ts b/src/types/index.ts index c7e8697..420efd0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -13,18 +13,16 @@ export interface SpotifyTrack { export interface SpotifyArtist { id: string; name: string; - genres: string[]; images: { url: string }[]; - popularity: number; external_urls: { spotify: string }; } export interface SpotifyPlaylist { id: string; name: string; - description: string; + description: string | null; images: { url: string }[]; - tracks: { total: number }; + items: { href: string; total: number } | null; external_urls: { spotify: string }; } @@ -40,6 +38,24 @@ export interface AudioFeatures { speechiness: number; } +export interface LastFmTag { + name: string; + count: number; + url: string; +} + +export interface LastFmTagsResponse { + toptags: { + tag: LastFmTag[]; + "@attr": { artist: string }; + }; +} + +export interface GenreEntry { + genre: string; + weight: number; +} + // AI context — the data package passed to Claude export interface SonaUserContext { displayName: string; @@ -50,24 +66,12 @@ export interface SonaUserContext { }[]; topArtists: { name: string; - genres: string[]; }[]; topArtistsAllTime: { name: string; }[]; - genreBreakdown: { - genre: string; - weight: number; - }[]; - audioFeatureAverages: { - energy: number; - danceability: number; - valence: number; - acousticness: number; - instrumentalness: number; - tempo: number; - loudness: number; - }; + // Derived from Last.fm artist.getTopTags + genreBreakdown: GenreEntry[]; } // API response wrappers From 9947adb8b14dd0be52e8f4315767ce42b2be953b Mon Sep 17 00:00:00 2001 From: lebuckman Date: Wed, 1 Apr 2026 20:35:11 -0700 Subject: [PATCH 3/9] docs: update PRD v1.1 and SYSTEM_DESIGN v1.1 - Document Last.fm genre integration replacing deprecated Spotify endpoints - Note Spotify 2026 breaking changes (genres, popularity, tracks->items) - Update cache TTL reference table with genre_breakdown 7-day TTL - Add Spotify development mode 25-user limit to target users section - Remove audio features from architecture and SonaUserContext --- docs/PRD.md | 315 +++++++++++--------------- docs/SYSTEM_DESIGN.md | 514 ++++++++++++++++++------------------------ 2 files changed, 350 insertions(+), 479 deletions(-) diff --git a/docs/PRD.md b/docs/PRD.md index 53d2d2a..e8a71ac 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1,9 +1,12 @@ # Sona — Product Requirements Document -**Version:** 1.0 +**Version:** 1.1 **Status:** Active -**Last Updated:** March 2026 -**Author:** Liam Buckman +**Last Updated:** April 2026 +**Author:** Liam Buckman +**Changelog:** + +- v1.1: Removed audio features (Spotify deprecated Nov 2024); added Last.fm genre integration; updated Spotify field changes from Feb 2026 API changelog; noted Spotify development mode 25-user limit --- @@ -53,6 +56,7 @@ There is no tool that uses AI to make listening data feel personal, expressive, - **No music playback** — Sona surfaces insights about music, it does not play it - **No recently played data** — omitted to reduce API surface and token cost; Spotify natively displays this for users +- **No audio features** — Spotify deprecated this endpoint for new apps in November 2024; genre signals are derived from Last.fm instead - **No social features in v1** — friend comparisons, shared profiles, and listening rooms are post-v1 - **No Apple Music support** — Spotify-only for initial release - **No mobile app** — web-only; responsive design supports mobile browsers @@ -69,189 +73,153 @@ There is no tool that uses AI to make listening data feel personal, expressive, **Secondary:** Developers and recruiters viewing the project as a portfolio piece. The application must be immediately understandable and impressive on first load. -**Persona — The Core User:** - -> Maya is a 22-year-old college student who listens to Spotify for 3+ hours a day. She checks her Wrapped every year and always screenshots it. She's curious about what her music says about her and would love something that explains her taste rather than just listing numbers. She doesn't want to read a dashboard — she wants to _ask a question_ and get a real answer. +> **Note:** Sona is currently in Spotify's Development Mode, which limits access to 25 users who must be manually allowlisted in the Spotify Developer Dashboard. A quota extension request will be submitted prior to public launch. --- ## 6. Feature Breakdown -### Sprint 1 — Foundation & Auth +### Sprint 1 — Foundation & Auth ✅ **Goal:** A working application where a Spotify user can log in, have their session and tokens persisted, and land on a placeholder profile page. -#### F-01 · Spotify OAuth 2.0 (Authorization Code Flow) +#### F-01 · Spotify OAuth 2.0 (Authorization Code Flow) ✅ - User clicks "Connect with Spotify" and is redirected to Spotify's authorization page - Spotify redirects back to `/api/auth/callback` with an authorization code - Server exchanges code for `access_token` and `refresh_token` -- Tokens are encrypted (AES-256) and stored in Neon Postgres — never in localStorage or client state +- Tokens are encrypted (AES-256-GCM) and stored in Neon Postgres — never in localStorage or client state - Session established via `iron-session` (encrypted, server-side cookie) - Token refresh handled automatically on expiry (transparent to the user) - `state` parameter used to prevent CSRF attacks -**Acceptance criteria:** A user can log in, refresh the page, and remain authenticated. Tokens visible in DB are encrypted ciphertext. Logging out clears session and tokens. +#### F-02 · User Record Persistence ✅ -#### F-02 · User Record Persistence - -- On first login, a user record is created in the `users` table (Spotify ID, display name, email, avatar URL) +- On first login, a user record is created in the `users` table - On subsequent logins, the existing record is updated (upsert) - Schema managed via Drizzle ORM with versioned migrations -#### F-03 · Protected Route Layout +#### F-03 · Protected Route Layout ✅ - All `/profile` and `/chat` routes require an active session - Unauthenticated users are redirected to `/` - Authenticated users visiting `/` are redirected to `/profile` -#### F-04 · Landing Page - -- Communicates Sona's value proposition clearly with a warm animated mesh gradient background -- Rotating headline demonstrating the product's scope -- **Hero input available to all users** — guest and authenticated alike (see F-09) -- Floating minimal nav (no background bar — link-style navigation) -- Two-column FAQ accordion section -- "Connect with Spotify" CTA visible but not forced +#### F-04 · Landing Page ✅ (placeholder — full design Sprint 4) --- -### Sprint 2 — Core Stats Dashboard +### Sprint 2 — Core Stats Data Layer ✅ (in progress) -**Goal:** Authenticated users see a meaningful, AI-narrated portfolio layout populated with real Spotify data. +**Goal:** Authenticated users have real Spotify data available via typed, cached API routes. -#### F-05 · Top Tracks +#### F-05 · Top Tracks ✅ -- Fetch top tracks via Spotify Web API (`/me/top/tracks`) -- Displayed as a ranked list within the "This Month" portfolio section -- Time range selector: Last 4 Weeks / Last 6 Months / All Time -- Data cached in database (TTL: 1 hour) to minimize redundant API calls +- Fetch via `/me/top/tracks` for short, medium, and long term +- Cached in DB (1-hour TTL) +- Transformed to lean shape before caching -#### F-06 · Top Artists +#### F-06 · Top Artists ✅ -- Fetch top artists via Spotify Web API (`/me/top/artists`) -- Displayed as an editorial grid in the "Defining Voices" section -- Same time range selector as top tracks -- Short-term and all-time artists both fetched and used in AI context +- Fetch via `/me/top/artists` for all time ranges +- `genres` and `popularity` fields removed — deprecated by Spotify Feb 2026 +- Cached in DB (1-hour TTL) -#### F-07 · Genre Breakdown +#### F-07 · Genre Breakdown via Last.fm ✅ -- Derived from top artists' genre arrays — aggregated, deduplicated, weighted by rank -- Displayed as animated horizontal bars within the Sound DNA section -- Top 5 genres surfaced; feeds into AI context +- Spotify's audio features and genre fields are deprecated for new apps +- Artist names from cached top artists are sent to Last.fm `artist.getTopTags` +- Tags are filtered (blocklist + count threshold + artist name exclusion) and weighted +- Within-artist normalization removes Last.fm popularity bias +- Claude interprets raw tags for the AI narrative — no over-engineering of filtering +- Cached in DB (7-day TTL) -#### F-08 · Audio Features Analysis +#### F-08 · Playlists ✅ -- Fetch audio features for top tracks (`/audio-features`) -- Compute averages: energy, danceability, valence, acousticness, instrumentalness, tempo, loudness -- Displayed as a visual "sound fingerprint" in the Sound DNA section -- These values are the primary input to AI insight and profile generation +- Fetch via `/me/playlists` +- `tracks` field renamed to `items` in Spotify's Feb 2026 changelog — updated +- Cached in DB (30-minute TTL) -#### F-09 · Guest AI Interaction (Landing Page) +#### F-09 · Guest AI Interaction (Landing Page) — Sprint 3 -- The landing page hero input is functional for unauthenticated users -- Guest queries receive general music intelligence responses (no personal Spotify data) -- Example: _"Generate me a 1-hour playlist for late-night studying"_ → Claude returns a curated tracklist with reasoning -- If a guest asks something requiring personal data, Sona surfaces a contextual sign-in prompt (non-blocking) -- If a guest wants to act on a response (e.g., import a generated playlist), they are prompted to connect Spotify at that moment — not before -- Guest sessions are rate-limited to prevent token abuse (see F-16) +- Landing page hero input functional for unauthenticated users +- General music intelligence responses (no personal Spotify data) +- Contextual sign-in prompt when personal data is needed + +#### F-10 · Profile Page UI — in progress + +- TanStack Query provider and hooks +- Portfolio layout with real data sections --- ### Sprint 3 — Sona AI Layer -**Goal:** Transform the stats portfolio into a genuinely intelligent experience. The AI is the narrator of every section and the interface users reach for when they want to go deeper. +**Goal:** Transform the stats portfolio into a genuinely intelligent experience. -#### F-10 · Sona Voice (Inline AI Narration) +#### F-11 · Sona Voice (Inline AI Narration) -- Each portfolio section opens with a short Sona-written observation in italic serif typography -- Rendered as a styled `SonaVoice` component — not a card or callout, but prose woven into the layout -- Generated from the user's actual data; cached per section per day -- Tone: second person, specific, grounded in data values, never generic +- Each portfolio section opens with a short Sona-written observation +- Generated from real user data; cached per section per day +- Tone: second person, specific, grounded in data, never generic -**Example (Artists section):** _"The Weeknd appears across your short-term, 6-month, and all-time charts. That kind of consistency isn't habit — it's identity."_ +#### F-12 · Sona Insights Card -#### F-11 · Sona Insights Card (Overview) +- Featured AI-generated summary at top of profile page, streamed live +- Synthesizes top artists, genre breakdown, and listening patterns +- Cached per user per day (24-hour TTL) -- A featured AI-generated summary at the top of the profile page, streamed live -- Synthesizes top genres, audio feature averages, and listening time patterns into a 2–4 sentence observation -- Cached per user per day (24-hour TTL); re-generates at midnight -- Streamed to the UI for a live typing effect +#### F-13 · Sona Profile (Music Personality) -#### F-12 · Sona Profile (Music Personality) +- Full AI-generated music personality profile +- Assigns a personality archetype +- Regeneratable once per 7 days; cached with 7-day TTL -- Full AI-generated music personality profile (3–5 paragraphs) -- Assigns a personality archetype (e.g., "The Restless Explorer") -- Structured around: genre identity, mood tendencies, listening behavior, defining artists -- Regeneratable by user once per 7 days -- Cached with 7-day TTL; overwritten on manual regeneration +#### F-14 · Sona Moods (Playlist Analysis) -#### F-13 · Sona Moods (Playlist Analysis) +- Classify playlists by mood using AI interpretation of names, track counts, and genre context +- Display as mood-tagged playlist cards -- Fetch user's playlists and sample audio features from each -- Classify each playlist into a mood: Hype / Chill / Happy / Melancholy / Focus -- Display as mood-tagged playlist cards in the Playlists section -- AI generates a one-line description per playlist based on its audio fingerprint +#### F-15 · Ask Sona — Agentic Chat Interface -#### F-14 · Ask Sona — Agentic Chat Interface - -- Dedicated `/chat` page with a full conversational interface -- **Architecture: lightweight agentic tool use.** Rather than pre-loading all user data into context, Claude is given a set of tools it can call based on what the user asks: - - `get_top_tracks(timeRange)` — fetches top tracks for a given range - - `get_top_artists(timeRange)` — fetches top artists for a given range - - `get_genre_breakdown()` — returns weighted genre list - - `get_audio_features()` — returns audio feature averages - - `get_playlist_moods()` — returns playlists with mood classifications -- Claude autonomously decides which tools to call to answer each question — this is the agentic pattern -- Conversation history maintained client-side for the session (not persisted in v1) -- AI responses streamed to the UI -- Also accessible via a floating shortcut button on the profile page -- Guest users can access a version of Ask Sona on the landing page with general (non-personal) responses +- Dedicated `/chat` page with conversational interface +- Claude uses tool use to fetch only the data needed per question +- Available tools: `get_top_tracks`, `get_top_artists`, `get_genre_breakdown`, `get_playlist_moods` +- Floating shortcut button on profile page +- Guest version on landing page with general (non-personal) responses --- ### Sprint 4 — Polish, A11y & Deployment -**Goal:** A publicly deployed, production-quality application ready for use. - -#### F-15 · Responsive Design +#### F-16 · Guest Rate Limiting -- All views functional and visually coherent on mobile (≥ 375px), tablet (≥ 768px), and desktop -- Portfolio sections reflow gracefully; charts resize correctly -- Floating Ask Sona shortcut repositions on mobile +- 10 AI requests per hour per IP via Upstash Redis at the Vercel Edge layer -#### F-16 · Guest Rate Limiting +#### F-17 · Responsive Design -- Unauthenticated users are limited to 10 AI requests per hour per IP -- Implemented via Upstash Redis + `@upstash/ratelimit` at the Vercel Edge layer -- Requests over the limit receive a 429 response with a message prompting sign-in for unlimited access -- Authenticated users are not rate-limited in v1 +- All views functional on mobile (≥ 375px), tablet (≥ 768px), and desktop -#### F-17 · Accessibility (A11y) +#### F-18 · Accessibility (A11y) -- All interactive elements keyboard-navigable with visible focus indicators -- Semantic HTML throughout (correct heading hierarchy, landmark regions) -- All images and icons have descriptive `alt` or `aria-label` -- Color contrast meets WCAG 2.1 AA minimums -- No motion for users with `prefers-reduced-motion` enabled +- Keyboard navigation, semantic HTML, ARIA labels, WCAG 2.1 AA contrast +- No motion for `prefers-reduced-motion` -#### F-18 · Loading, Error & Empty States +#### F-19 · Loading, Error & Empty States -- Every data-fetching component has a skeleton loading state (Shadcn Skeleton) -- API errors surface a user-friendly message with retry option -- Empty states handled gracefully (first-time users with sparse data) +- Skeleton loading states for all data-fetching components +- User-friendly error messages with retry +- Graceful empty states for first-time users -#### F-19 · Vercel Deployment +#### F-20 · Vercel Deployment ✅ -- Project deployed to Vercel with environment variables configured -- Preview deployments enabled for pull requests -- `SPOTIFY_REDIRECT_URI` updated for production domain +- Live at `https://sonamusic.vercel.app` +- Preview deployments on all PRs -#### F-20 · README & Documentation +#### F-21 · README & Documentation ✅ -- README: project description, tech stack, architecture summary, setup instructions, live demo link, screenshots -- `docs/` folder contains PRD and SYSTEM_DESIGN -- `.env.example` checked in with all required variable names (no values) +- README with tech stack, setup instructions, live demo link +- `docs/` folder with PRD and SYSTEM_DESIGN --- @@ -263,77 +231,56 @@ User Browser ▼ Next.js 16 (App Router) — Vercel │ - ├── / (landing) Public — guest AI interaction available + ├── / (landing) Public — guest AI interaction ├── /profile Protected — portfolio layout, AI narration ├── /chat Protected — agentic Ask Sona interface - ├── /api/auth/* OAuth routes (login, callback, logout) - ├── /api/spotify/* Spotify data proxy (server-side, cached) - └── /api/ai/* AI generation + agentic chat (server-side) + ├── /api/auth/* OAuth routes + ├── /api/spotify/* Spotify data proxy (cached) + ├── /api/lastfm/* Last.fm genre data (cached) + └── /api/ai/* AI generation + agentic chat │ ├── Vercel Edge Middleware Guest rate limiting (Upstash Redis) - ├── Spotify Web API OAuth 2.0 + data (server-side only) - ├── Anthropic API Claude Haiku 4.5 with tool use (server-side only) - └── Neon Postgres Users, encrypted tokens, Spotify cache, AI cache + ├── Spotify Web API OAuth 2.0 + top tracks/artists/playlists + ├── Last.fm API Artist genre tags (artist.getTopTags) + ├── Anthropic API Claude Haiku 4.5 with tool use + └── Neon Postgres Users, tokens, Spotify cache, AI cache └── Drizzle ORM Type-safe queries + versioned migrations ``` -**Key architectural principles:** - -- All Spotify API calls are server-side — the client never holds a Spotify access token -- Anthropic API key is server-side only — never exposed to the browser -- Spotify data is cached in Neon to reduce redundant API calls -- AI-generated content is cached to control cost (insights: 24h, profile: 7d) -- Chat context is built dynamically via tool use — Claude fetches only what it needs -- Guest interactions hit the same AI endpoints but without personal context; rate-limited at the edge - --- -## 8. Data Model (High-Level) +## 8. Data Model ``` users - id uuid, primary key - spotify_id varchar, unique - display_name varchar - email varchar - avatar_url varchar - country varchar - spotify_product varchar ("premium", "free") - created_at timestamp - updated_at timestamp + id, spotify_id, display_name, email, avatar_url, + country, spotify_product, created_at, updated_at tokens - id uuid, primary key - user_id uuid, FK → users (CASCADE DELETE) - access_token text (AES-256 encrypted) - refresh_token text (AES-256 encrypted) - expires_at timestamp - scope text - updated_at timestamp + id, user_id (FK), access_token (encrypted), refresh_token (encrypted), + expires_at, scope, updated_at spotify_cache - id uuid, primary key - user_id uuid, FK → users (CASCADE DELETE) - cache_key varchar ("top_tracks:short_term", "audio_features", etc.) - data jsonb - cached_at timestamp - expires_at timestamp + id, user_id (FK), cache_key, data (jsonb), cached_at, expires_at UNIQUE (user_id, cache_key) + Cache keys and TTLs: + - top_tracks:{short|medium|long}_term → 1 hour + - top_artists:{short|medium|long}_term → 1 hour + - playlists → 30 minutes + - genre_breakdown → 7 days (Last.fm tags, very stable) + ai_cache - id uuid, primary key - user_id uuid, FK → users (CASCADE DELETE) - cache_type varchar ("daily_insight", "profile", "mood_analysis") - content text - model varchar - input_tokens integer - output_tokens integer - generated_at timestamp - expires_at timestamp + id, user_id (FK), cache_type, content, model, + input_tokens, output_tokens, generated_at, expires_at UNIQUE (user_id, cache_type) + + Cache types and TTLs: + - daily_insight → 24 hours + - profile → 7 days ``` -**Note:** `recently_played` is intentionally omitted. Spotify surfaces this natively; including it adds API surface and token cost without meaningfully improving the AI context. +**Note:** `recently_played` and `audio_features` are intentionally absent. Recently played is shown natively by Spotify. Audio features are deprecated for new apps as of November 2024. --- @@ -346,12 +293,12 @@ ai_cache | Styling | Tailwind CSS | v4 | | Components | Shadcn UI | latest | | Data Fetching | TanStack Query | v5 | -| Charts | Recharts | v2 | | ORM | Drizzle ORM | latest | | Database | Neon Postgres | serverless | | Session | iron-session | latest | | Validation | Zod | latest | | AI | Anthropic Claude Haiku 4.5 | latest | +| Genre Data | Last.fm API | v2 | | Rate Limiting | Upstash Redis + @upstash/ratelimit | latest | | Deployment | Vercel | — | | CI | GitHub Actions | — | @@ -371,39 +318,37 @@ ai_cache **Engineering quality** - CI pipeline is green on `main` with every commit -- Codebase demonstrates: OAuth 2.0, encrypted token storage, database caching, agentic LLM integration, TypeScript strict mode, and edge rate limiting +- Codebase demonstrates: OAuth 2.0, AES-256-GCM encryption, DB caching with multiple TTLs, agentic LLM integration, multi-API orchestration, TypeScript strict mode, and edge rate limiting - Conventional commit history is clean and readable as a project narrative - `docs/` folder contains up-to-date PRD and system design document -- `.env.example` is complete and accurate --- ## 11. Sprint Timeline -| Sprint | Focus | Target | -| -------- | ------------------------------------------------- | ------- | -| Sprint 1 | Foundation, Auth, DB schema | Week 1 | -| Sprint 2 | Core Stats + Guest AI Interaction | Week 2 | -| Sprint 3 | Sona AI Layer (narration, insights, agentic chat) | Week 3 | -| Sprint 4 | Polish, A11y, Rate Limiting, Deploy | Week 4+ | +| Sprint | Focus | Status | +| -------- | ----------------------------------- | -------------- | +| Sprint 1 | Foundation, Auth, DB schema | ✅ Complete | +| Sprint 2 | Core Stats + Data Layer | 🔄 In Progress | +| Sprint 3 | Sona AI Layer | Upcoming | +| Sprint 4 | Polish, A11y, Rate Limiting, Deploy | Upcoming | --- ## 12. Post-v1 Roadmap -Features intentionally deferred but worth building after initial launch: - -- **Shareable public profile** (`/u/username`) — a public URL for your music identity page that anyone can view without signing in. A natural viral/social mechanic. -- **Playlist generation with Spotify import** — generate a playlist via AI, then push it directly to the user's Spotify account using the `playlist-modify-public` scope -- **Social listening rooms** — real-time WebSocket-based listening sessions with AI-enhanced activity (e.g., Sona commenting on what the group is hearing) -- **Persistent chat history** — store conversation history per user in the database across sessions -- **Apple Music support** — extend the data pipeline to support Apple Music's API alongside Spotify +- **Shareable public profile** (`/u/username`) — public URL for your music identity +- **Playlist generation with Spotify import** — AI-generated playlists pushed to Spotify +- **Social listening rooms** — real-time WebSocket sessions with AI commentary +- **Persistent chat history** — store conversations across sessions +- **Apple Music support** — extend data pipeline beyond Spotify +- **Spotify quota extension** — apply for Extended Quota Mode to remove 25-user limit --- ## 13. Open Questions -- [ ] Should guest AI responses (landing page) use a lighter/faster model than authenticated responses to reduce cost? (e.g., guest → Haiku, authenticated → Sonnet) -- [ ] Should chat history be sent as part of the tool-use message array, or maintained separately from tool results? -- [ ] What is the Upstash Redis plan sufficient for expected guest traffic — is the free tier adequate? -- [ ] Should the public profile page (post-v1) require opt-in from the user, or be opt-out? +- [ ] Should guest AI responses use a lighter model than authenticated responses? +- [ ] Should chat history be sent as part of the tool-use message array or maintained separately? +- [ ] Should the public profile page (post-v1) require explicit opt-in from the user? +- [ ] When should we apply for Spotify quota extension — before or after v1.0 launch? diff --git a/docs/SYSTEM_DESIGN.md b/docs/SYSTEM_DESIGN.md index 33d819f..8ea33b5 100644 --- a/docs/SYSTEM_DESIGN.md +++ b/docs/SYSTEM_DESIGN.md @@ -1,36 +1,38 @@ # Sona — System Design Document -**Version:** 1.0 +**Version:** 1.1 **Status:** Active -**Last Updated:** March 2026 -**Companion:** [PRD.md](./PRD.md) +**Last Updated:** April 2026 +**Companion:** [PRD.md](./PRD.md) +**Changelog:** Added Last.fm integration; removed audio features (deprecated); updated Spotify field changes from Feb 2026; updated cache key reference; added genre breakdown data flow --- ## 1. Architecture Overview -Sona is a server-centric Next.js 16 application. The core principle is that **all sensitive operations happen on the server** — Spotify tokens, the Anthropic API key, and token refresh logic never touch the client. The browser only ever sees data that has already been fetched, transformed, and returned by a Next.js API route. +Sona is a server-centric Next.js 16 application. The core principle is that **all sensitive operations happen on the server** — Spotify tokens, the Anthropic API key, the Last.fm API key, and token refresh logic never touch the client. The browser only ever sees data that has already been fetched, transformed, and returned by a Next.js API route. ``` -┌─────────────────────────────────────────────────────────┐ -│ Browser (Client) │ -│ Next.js Pages · TanStack Query · Recharts · Shadcn UI │ -└────────────────────────┬────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────┐ +│ Browser (Client) │ +│ Next.js Pages · TanStack Query · Recharts · Shadcn UI │ +└────────────────────────┬────────────────────────────────────┘ │ HTTPS (internal API routes) -┌────────────────────────▼────────────────────────────────┐ -│ Next.js 16 — Vercel Edge │ -│ │ -│ App Router (RSC + Client Components) │ -│ ├── /api/auth/* OAuth flow & session mgmt │ -│ ├── /api/spotify/* Spotify data proxy │ -│ └── /api/ai/* AI generation & chat │ -└──────┬───────────────────────┬───────────────────────── ┘ - │ │ -┌──────▼──────┐ ┌──────────▼──────────┐ -│ Spotify │ │ Anthropic │ -│ Web API │ │ Claude Haiku 4.5 │ -│ (OAuth 2.0) │ │ (server-side only) │ -└─────────────┘ └─────────────────────┘ +┌────────────────────────▼────────────────────────────────────┐ +│ Next.js 16 — Vercel Edge │ +│ │ +│ App Router (RSC + Client Components) │ +│ ├── /api/auth/* OAuth flow & session mgmt │ +│ ├── /api/spotify/* Spotify data proxy │ +│ ├── /api/lastfm/* Last.fm genre data │ +│ └── /api/ai/* AI generation & chat │ +└──────┬──────────────────┬──────────────────┬───────────────┘ + │ │ │ +┌──────▼──────┐ ┌────────▼──────┐ ┌───────▼──────────────┐ +│ Spotify │ │ Last.fm │ │ Anthropic │ +│ Web API │ │ Web API │ │ Claude Haiku 4.5 │ +│ (OAuth 2.0) │ │ (API key) │ │ (server-side only) │ +└─────────────┘ └───────────────┘ └──────────────────────┘ │ ┌──────▼──────────────────────────┐ │ Neon Postgres (Serverless) │ @@ -48,60 +50,56 @@ Sona is a server-centric Next.js 16 application. The core principle is that **al ### Public Routes (unauthenticated) -| Route | Type | Description | -| -------------------- | ---------- | ---------------------------------------- | -| `/` | Page (RSC) | Landing page — hero input, features, FAQ | -| `/api/auth/login` | API Route | Initiates Spotify OAuth redirect | -| `/api/auth/callback` | API Route | Handles OAuth code exchange | +| Route | Type | Description | +| -------------------- | ---------- | ---------------------------------------------- | +| `/` | Page (RSC) | Landing page — hero input, features, FAQ | +| `/api/auth/login` | API Route | Initiates Spotify OAuth redirect | +| `/api/auth/callback` | API Route | Handles OAuth code exchange | +| `/api/ai/chat` | API Route | Guest AI responses (general, no personal data) | ### Protected Routes (require session) -| Route | Type | Description | -| ------------------------------ | ---------- | --------------------------------------------- | -| `/profile` | Page (RSC) | Music identity portfolio — all stats sections | -| `/chat` | Page (RSC) | Ask Sona — full chat interface | -| `/generate` | Page (RSC) | Playlist generation — _Sprint 4_ | -| `/api/auth/logout` | API Route | Clears session and tokens | -| `/api/spotify/top-tracks` | API Route | Top tracks (time range param) | -| `/api/spotify/top-artists` | API Route | Top artists (time range param) | -| `/api/spotify/recently-played` | API Route | Last 20 played tracks | -| `/api/spotify/audio-features` | API Route | Audio features for top tracks | -| `/api/spotify/playlists` | API Route | User's playlists | -| `/api/ai/insights` | API Route | Daily AI insight card (streamed) | -| `/api/ai/profile` | API Route | AI music personality profile (streamed) | -| `/api/ai/chat` | API Route | Conversational AI (streamed) | - -### Route Protection Strategy - -A single layout at `app/(protected)/layout.tsx` handles auth guarding for all dashboard routes. It reads the iron-session server-side and redirects unauthenticated users to `/` before any child page renders. Authenticated users visiting `/` are redirected to `/profile`. +| Route | Type | Description | +| ----------------------------- | ---------- | ------------------------------------- | +| `/profile` | Page (RSC) | Music identity portfolio | +| `/chat` | Page (RSC) | Ask Sona — full chat interface | +| `/api/auth/logout` | API Route | Clears session and tokens | +| `/api/spotify/top-tracks` | API Route | Top tracks (time range param) | +| `/api/spotify/top-artists` | API Route | Top artists (time range param) | +| `/api/spotify/playlists` | API Route | User's playlists | +| `/api/lastfm/genre-breakdown` | API Route | Weighted genre tags via Last.fm | +| `/api/ai/insights` | API Route | Daily AI insight (streamed) | +| `/api/ai/profile` | API Route | Music personality profile (streamed) | +| `/api/ai/chat` | API Route | Agentic chat with tool use (streamed) | + +**Note:** `/api/spotify/audio-features` was removed. Spotify deprecated this endpoint for new apps in November 2024, returning 403 for all requests. Genre signals are now derived from Last.fm. --- -## 3. Authentication Flow — Spotify OAuth 2.0 (Authorization Code Flow) +## 3. Authentication Flow — Spotify OAuth 2.0 ``` User Sona Server Spotify │ │ │ - │ Click "Connect" │ │ + │ Click "Connect" │ │ ├──────────────────────▶│ │ - │ │ Build auth URL │ - │ │ + random state val │ + │ │ Generate random │ + │ │ state value, store │ + │ │ in iron-session │ │ 302 → Spotify login │ │ │◀──────────────────────┤ │ - │ │ │ │ Authorize Sona │ │ ├───────────────────────────────────────────▶ │ - │ │ │ │ 302 → /api/auth/callback?code=X&state=Y │ ├──────────────────────▶│ │ - │ │ Validate state │ + │ │ Validate state ✓ │ │ │ POST /api/token │ │ ├─────────────────────▶│ │ │ access + refresh │ │ │◀─────────────────────┤ │ │ Upsert user in DB │ - │ │ Encrypt + store │ - │ │ tokens in DB │ + │ │ AES-256-GCM encrypt │ + │ │ Store tokens in DB │ │ │ Set iron-session │ │ 302 → /profile │ │ │◀──────────────────────┤ │ @@ -109,28 +107,26 @@ User Sona Server Spotify **Security decisions:** -- `state` parameter is a cryptographic random string stored in session before redirect, verified on callback — prevents CSRF -- Tokens are AES-256 encrypted before storage using `SESSION_SECRET` as the key. Even if the database is compromised, raw tokens are not exposed -- `iron-session` cookie is `httpOnly`, `secure`, and `sameSite: lax` -- Spotify scopes are minimal — only what the product actually needs (see Section 7) +- `state` parameter is cryptographically random, stored in session, verified on callback — prevents CSRF +- Tokens encrypted with AES-256-GCM before DB storage — nonce + auth tag + ciphertext pattern prevents both unauthorized reads and tampering +- `iron-session` cookie is `httpOnly` (XSS protection), `secure` in production (HTTPS only), `sameSite: lax` (CSRF protection) +- Spotify scopes are minimal — only what the product needs ### Token Refresh -Every Spotify API route calls a shared `withValidToken(userId)` helper before executing. This helper: +Every Spotify API route calls `withValidToken(userId)` before executing: -1. Reads the token record from DB and decrypts it +1. Reads token record from DB and decrypts it 2. Checks if `expires_at` is within 5 minutes -3. If expiring soon: calls Spotify's refresh endpoint, updates DB with new tokens and new `expires_at` -4. Returns the valid access token +3. If expiring: calls Spotify's refresh endpoint, updates DB with new tokens +4. Returns valid access token -Token refresh is transparent to the client — the API route handles it internally before responding. +Token refresh is transparent to the client — handled entirely server-side. --- ## 4. Database Schema -Managed via Drizzle ORM. All migrations are versioned in `drizzle/migrations/`. - ### `users` ```sql @@ -140,7 +136,7 @@ display_name varchar(255) email varchar(255) avatar_url text country varchar(10) -spotify_product varchar(50) -- "premium", "free", etc. +spotify_product varchar(50) created_at timestamp NOT NULL DEFAULT now() updated_at timestamp NOT NULL DEFAULT now() ``` @@ -150,14 +146,12 @@ updated_at timestamp NOT NULL DEFAULT now() ```sql id uuid PRIMARY KEY DEFAULT gen_random_uuid() user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE -access_token text NOT NULL -- AES-256 encrypted -refresh_token text NOT NULL -- AES-256 encrypted +access_token text NOT NULL -- AES-256-GCM encrypted +refresh_token text NOT NULL -- AES-256-GCM encrypted expires_at timestamp NOT NULL -scope text -- space-separated Spotify scopes granted -created_at timestamp NOT NULL DEFAULT now() +scope text updated_at timestamp NOT NULL DEFAULT now() - -UNIQUE (user_id) -- one token record per user, updated on refresh +UNIQUE (user_id) ``` ### `spotify_cache` @@ -166,15 +160,11 @@ UNIQUE (user_id) -- one token record per user, updated on refresh id uuid PRIMARY KEY DEFAULT gen_random_uuid() user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE cache_key varchar(200) NOT NULL --- Examples: "top_tracks:short_term", "top_tracks:medium_term", --- "top_artists:short_term", "recently_played", --- "audio_features", "playlists" data jsonb NOT NULL cached_at timestamp NOT NULL DEFAULT now() expires_at timestamp NOT NULL - UNIQUE (user_id, cache_key) -INDEX (user_id, cache_key, expires_at) -- fast lookup + expiry check +INDEX (user_id, cache_key, expires_at) ``` ### `ai_cache` @@ -183,33 +173,33 @@ INDEX (user_id, cache_key, expires_at) -- fast lookup + expiry check id uuid PRIMARY KEY DEFAULT gen_random_uuid() user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE cache_type varchar(100) NOT NULL --- Values: "daily_insight", "profile", "mood_analysis" content text NOT NULL -model varchar(100) -- e.g. "claude-haiku-4-5" +model varchar(100) input_tokens integer output_tokens integer generated_at timestamp NOT NULL DEFAULT now() expires_at timestamp NOT NULL - UNIQUE (user_id, cache_type) --- One current record per type per user; overwritten on regeneration ``` ### Cache TTL Reference -| Data | TTL | Rationale | -| -------------------- | ------------ | ------------------------------------------- | -| Top tracks / artists | 1 hour | Changes slowly; reduce Spotify API calls | -| Recently played | 15 minutes | More dynamic; users expect freshness | -| Playlists | 30 minutes | Changes occasionally | -| Audio features | 6 hours | Static per-track data; rarely needs refresh | -| AI daily insight | 24 hours | Regenerates once per day | -| AI profile | 7 days | User can force regenerate; rate-limited | -| Chat context | Session only | Not persisted in v1 | +| Cache Key | TTL | Source | Rationale | +| -------------------------- | ---------- | ------- | -------------------------- | +| `top_tracks:{range}` | 1 hour | Spotify | Changes slowly | +| `top_artists:{range}` | 1 hour | Spotify | Changes slowly | +| `playlists` | 30 minutes | Spotify | Changes occasionally | +| `genre_breakdown` | 7 days | Last.fm | Genre tags are very stable | +| `daily_insight` (ai_cache) | 24 hours | Claude | Regenerates daily | +| `profile` (ai_cache) | 7 days | Claude | User can force regenerate | + +**Intentionally absent:** `recently_played` (Spotify shows this natively), `audio_features` (deprecated by Spotify Nov 2024). --- -## 5. Data Flow — Spotify Data +## 5. Data Flow — Spotify + Last.fm + +### Spotify Routes (top tracks, top artists, playlists) ``` Client (TanStack Query hook) @@ -219,27 +209,54 @@ Client (TanStack Query hook) Next.js API Route │ ├─ Read iron-session → user_id - │ - ├─ Query spotify_cache WHERE user_id = ? AND cache_key = 'top_tracks:short_term' + ├─ Query spotify_cache WHERE user_id = ? AND cache_key = ? │ AND expires_at > now() │ - ├─ CACHE HIT ──────────────────▶ Return cached data as JSON + ├─ CACHE HIT ──────────────────▶ Return cached data │ └─ CACHE MISS │ - ├─ withValidToken(user_id) → access_token (refreshes if needed) + ├─ withValidToken(user_id) → valid access_token + ├─ Fetch from Spotify API + ├─ Transform (select only fields Sona needs) + ├─ Upsert into spotify_cache with TTL + └─ Return transformed data +``` + +### Genre Breakdown Route + +``` +GET /api/lastfm/genre-breakdown + │ + ├─ Check spotify_cache for 'genre_breakdown' (7-day TTL) + ├─ CACHE HIT ──────────────────▶ Return immediately + │ + └─ CACHE MISS │ - ├─ GET https://api.spotify.com/v1/me/top/tracks?time_range=short_term + ├─ Read top_artists:short_term from spotify_cache + │ (no Spotify API call — reads existing cache) + │ If not cached → return [] and let client retry │ - ├─ Transform response (select only fields Sona needs) + ├─ For each of top 20 artist names: + │ → GET ws.audioscrobbler.com/2.0/?method=artist.gettoptags + │ → 100ms delay between requests (respectful API usage) │ - ├─ Upsert into spotify_cache with expires_at = now() + 1 hour + ├─ Filter tags (blocklist + count threshold + artist name exclusion) + ├─ Normalize tag spelling variants + ├─ Weight: artist rank × within-artist normalized tag score + ├─ Aggregate → top 8 weighted genre entries │ - └─ Return transformed data as JSON + ├─ Upsert into spotify_cache with 7-day TTL + └─ Return genre breakdown + +SYSTEM DESIGN NOTE: This route has an implicit dependency on +top_artists:short_term being cached. In the UI, top artists are +always fetched before genre breakdown, so this is always satisfied +in practice. The graceful empty return handles the edge case. ``` -**Why proxy through Next.js instead of calling Spotify directly from the client?** -The Spotify access token would have to be sent to the browser, where it could be extracted. By proxying server-side, the token never leaves the server. This also lets us add caching, rate limit handling, and response transformation in one place. +**Why Last.fm for genres?** +Spotify deprecated `genres` on artist objects and the `/audio-features` endpoint for new apps in late 2024 and Feb 2026 respectively. Last.fm's `artist.getTopTags` is a stable, free alternative with rich crowd-sourced genre data. We pass raw tags to Claude for interpretation rather than over-engineering our own filtering — Claude is better at understanding that "k-pop" and "korean" are related than any hand-crafted rule system. --- @@ -250,70 +267,61 @@ The Spotify access token would have to be sent to the browser, where it could be ``` Client requests /api/ai/insights │ - ├─ Check ai_cache for today's insight - │ - ├─ CACHE HIT ──────────────────▶ Stream cached content to client + ├─ Check ai_cache for today's insight (24hr TTL) + ├─ CACHE HIT ──────────────────▶ Stream cached content │ └─ CACHE MISS │ - ├─ Fetch from spotify_cache (or trigger fetch if stale): - │ top_tracks:short_term - │ top_artists:short_term - │ audio_features - │ recently_played - │ - ├─ Build context object: - │ { - │ topTracks: [...names and artists], - │ topArtists: [...names and genres], - │ audioFeatureAverages: { energy, danceability, valence, ... }, - │ topGenres: [...weighted genre list], - │ listeningTimePattern: { morning, afternoon, evening, lateNight } - │ } - │ - ├─ Construct prompt (see Section 8 for prompt strategy) + ├─ Build SonaUserContext from spotify_cache: + │ - top_tracks:short_term (names + artists) + │ - top_artists:short_term (names) + │ - top_artists:long_term (names, for "all time" context) + │ - genre_breakdown (weighted tag list from Last.fm) │ + ├─ Construct prompt with user context ├─ Stream response from Claude Haiku 4.5 - │ - ├─ On stream complete: store full text in ai_cache - │ expires_at = next midnight (resets daily) - │ - └─ Stream tokens to client as they arrive + ├─ On complete: store in ai_cache (expires next midnight) + └─ Stream tokens to client ``` -### Chat (`/api/ai/chat`) +### Chat (`/api/ai/chat`) — Agentic Tool Use ``` Client sends { message: string, history: Message[] } │ - ├─ Fetch user context from spotify_cache - │ (same context object as insights, built once per session) + ├─ Build system prompt with behavior instructions + │ + ├─ Define available tools: + │ - get_top_tracks(timeRange) + │ - get_top_artists(timeRange) + │ - get_genre_breakdown() + │ - get_playlist_moods() │ - ├─ Build messages array: - │ [ - │ { role: "system", content: }, - │ ...history (last N turns), - │ { role: "user", content: message } - │ ] + ├─ Send to Claude with tools defined │ - ├─ Stream from Claude Haiku 4.5 + ├─ Claude decides which tools to call based on the question + │ (this is the agentic pattern — Claude plans before responding) │ - └─ Stream tokens to client - (conversation history managed client-side by TanStack Query) + ├─ Server executes tool calls → reads from spotify_cache + │ (no live Spotify API calls during chat — uses cached data) + │ + ├─ Claude receives tool results → generates response + └─ Stream response to client + +SYSTEM DESIGN NOTE: Tool use is the key architectural difference +between a basic chatbot and an agent. Claude autonomously decides +what data it needs rather than receiving a pre-built context dump. +This is more efficient (less unnecessary data transfer) and more +capable (Claude can reason about what to look up). ``` -**Conversation history management:** The client maintains the full conversation history in TanStack Query state and sends it with each request. The server is stateless for chat in v1 — no messages are persisted. This keeps the architecture simple while delivering the full chat experience. - --- ## 7. Spotify API Scopes -Only the minimum scopes required are requested. This is both a security principle and a Spotify API requirement — requesting unnecessary scopes can cause app rejection during review. - | Scope | Why needed | | ----------------------------- | ----------------------------- | | `user-top-read` | Top artists and tracks | -| `user-read-recently-played` | Recently played history | | `playlist-read-private` | User's private playlists | | `playlist-read-collaborative` | Collaborative playlists | | `user-read-email` | Email for user record | @@ -325,86 +333,43 @@ Only the minimum scopes required are requested. This is both a security principl - `user-library-*` — Liked songs not needed for v1 - `streaming` — No playback +**Note:** Sona is currently in Spotify's Development Mode. Apps in this mode are limited to 25 users who must be manually added to the allowlist in the Spotify Developer Dashboard. Extended Quota Mode requires 250k MAU and a formal application — this is a post-v1 goal. + --- ## 8. AI Prompt Strategy -The AI layer is one of the most important parts of Sona's technical identity. Prompt quality directly determines product quality. - -### Context Object (sent with every AI request) +### SonaUserContext Type ```typescript type SonaUserContext = { displayName: string; topTracks: { name: string; artist: string }[]; // top 10, short term - topArtists: { name: string; genres: string[] }[]; // top 10, short term - topArtistsLongTerm: { name: string }[]; // top 5, all time - genreBreakdown: { genre: string; weight: number }[]; // top 5 weighted genres - audioFeatureAverages: { - energy: number; // 0–1 - danceability: number; // 0–1 - valence: number; // 0–1 (musical "happiness") - acousticness: number; // 0–1 - instrumentalness: number; - tempo: number; // BPM - loudness: number; // dB - }; - recentlyPlayed: { name: string; artist: string; playedAt: string }[]; // last 20 - listeningTimePattern: { - morning: number; // proportion 0–1 - afternoon: number; - evening: number; - lateNight: number; - }; + topArtists: { name: string }[]; // top 10, short term + topArtistsAllTime: { name: string }[]; // top 5, long term + genreBreakdown: { genre: string; weight: number }[]; // from Last.fm, top 8 }; ``` +**Note:** `audioFeatureAverages` (energy, danceability, valence, etc.) was removed from the context because Spotify's audio features endpoint is deprecated for new apps. Genre tags from Last.fm serve as the primary mood/character signal for the AI layer. + ### System Prompt Principles - Sona speaks in the **second person** ("You gravitate toward..." not "The user listens to...") -- Observations are **specific and grounded** in actual data values — no generic platitudes +- Observations are **specific and grounded** in actual data — no generic platitudes - Tone is **calm, perceptive, slightly poetic** — not clinical or chatbot-like -- Sona **does not overstate certainty** ("suggests" not "means") +- Claude interprets raw genre tags intelligently — "k-pop", "korean", "thai" are understood as a cohesive regional listening identity, not unrelated labels - Responses are **concise** — insights are 2–4 sentences, not essays -- Chat responses can be longer but should always **ground claims in the user's actual data** -### Caching & Cost Control +### Cost Control -- **Prompt caching:** The system prompt + user context object is the same across all chat turns in a session. Using Anthropic's prompt caching (90% discount on cache hits) keeps chat affordable. -- **Model selection:** Claude Haiku 4.5 for all features. At estimated usage (a few dozen requests per user per month), cost is well under $0.10/user/month. -- **AI cache:** Daily insights and profile are cached 24h/7d respectively — the most token-expensive generation only happens once per period, not on every page load. +- **Prompt caching:** System prompt + user context is cached across chat turns (90% discount on cache hits) +- **Model selection:** Claude Haiku 4.5 for all features — fast, cheap, sufficient capability +- **AI cache:** Insights and profile are cached 24h/7d — expensive generation only happens once per period --- -## 9. Client-Side Data Fetching (TanStack Query) - -All data fetching on the client goes through TanStack Query hooks. This provides automatic caching, background refresh, loading/error states, and deduplication. - -```typescript -// Example hook — src/hooks/use-top-tracks.ts -export function useTopTracks(timeRange: TimeRange = "short_term") { - return useQuery({ - queryKey: ["top-tracks", timeRange], - queryFn: () => fetch(`/api/spotify/top-tracks?range=${timeRange}`).then((r) => r.json()), - staleTime: 1000 * 60 * 5, // consider fresh for 5 minutes client-side - gcTime: 1000 * 60 * 60, // keep in memory for 1 hour - }); -} -``` - -**Query key conventions:** - -- `['top-tracks', timeRange]` — top tracks by time range -- `['top-artists', timeRange]` — top artists by time range -- `['recently-played']` — recently played -- `['audio-features']` — audio feature averages -- `['playlists']` — user's playlists -- `['ai', 'insights']` — daily insight -- `['ai', 'profile']` — music personality profile - ---- - -## 10. Project File Structure +## 9. Project File Structure ``` sona/ @@ -413,132 +378,93 @@ sona/ │ │ ├── (public)/ │ │ │ └── page.tsx # Landing page │ │ ├── (protected)/ -│ │ │ ├── layout.tsx # Auth guard → redirect if no session -│ │ │ ├── profile/ -│ │ │ │ ├── page.tsx # Portfolio page (RSC shell) -│ │ │ │ └── _components/ # Page-specific components -│ │ │ │ ├── identity-section.tsx -│ │ │ │ ├── artists-section.tsx -│ │ │ │ ├── sound-section.tsx -│ │ │ │ ├── tracks-section.tsx -│ │ │ │ └── playlists-section.tsx -│ │ │ └── chat/ -│ │ │ └── page.tsx # Ask Sona page -│ │ ├── api/ -│ │ │ ├── auth/ -│ │ │ │ ├── login/route.ts -│ │ │ │ ├── callback/route.ts -│ │ │ │ └── logout/route.ts -│ │ │ ├── spotify/ -│ │ │ │ ├── top-tracks/route.ts -│ │ │ │ ├── top-artists/route.ts -│ │ │ │ ├── recently-played/route.ts -│ │ │ │ ├── audio-features/route.ts -│ │ │ │ └── playlists/route.ts -│ │ │ └── ai/ -│ │ │ ├── insights/route.ts -│ │ │ ├── profile/route.ts -│ │ │ └── chat/route.ts -│ │ ├── globals.css -│ │ └── layout.tsx # Root layout (fonts, providers) +│ │ │ ├── layout.tsx # Auth guard +│ │ │ ├── profile/page.tsx # Portfolio page +│ │ │ └── chat/page.tsx # Ask Sona page +│ │ └── api/ +│ │ ├── auth/{login,callback,logout}/route.ts +│ │ ├── spotify/{top-tracks,top-artists,playlists}/route.ts +│ │ ├── lastfm/genre-breakdown/route.ts +│ │ └── ai/{insights,profile,chat}/route.ts │ │ │ ├── components/ -│ │ ├── ui/ # Shadcn components (auto-generated) -│ │ ├── layout/ -│ │ │ ├── app-nav.tsx # Portfolio page nav -│ │ │ └── landing-nav.tsx # Landing floating nav -│ │ ├── sona/ -│ │ │ ├── insight-card.tsx # Streaming AI insight display -│ │ │ ├── chat-interface.tsx # Ask Sona chat UI -│ │ │ ├── ask-sona-float.tsx # Floating shortcut button -│ │ │ └── sona-voice.tsx # Styled italic Sona narration text -│ │ └── charts/ -│ │ ├── genre-bars.tsx -│ │ ├── audio-features.tsx -│ │ └── activity-chart.tsx +│ │ ├── ui/ # Shadcn components +│ │ ├── layout/ # Nav components +│ │ ├── sona/ # AI insight components +│ │ └── charts/ # Data visualizations │ │ -│ ├── hooks/ +│ ├── hooks/ # TanStack Query hooks │ │ ├── use-top-tracks.ts │ │ ├── use-top-artists.ts -│ │ ├── use-audio-features.ts │ │ ├── use-playlists.ts -│ │ └── use-ai-insights.ts +│ │ └── use-genre-breakdown.ts │ │ │ ├── lib/ │ │ ├── spotify/ -│ │ │ ├── client.ts # Fetch wrapper for Spotify API -│ │ │ ├── auth.ts # OAuth helpers, token exchange -│ │ │ ├── token.ts # withValidToken(), refresh logic -│ │ │ └── types.ts # TypeScript types for Spotify responses +│ │ │ ├── client.ts # Spotify API fetch wrapper +│ │ │ ├── auth.ts # OAuth helpers +│ │ │ ├── token.ts # withValidToken() +│ │ │ └── cache.ts # Cache-aside utilities +│ │ ├── lastfm/ +│ │ │ ├── client.ts # Last.fm fetch wrapper +│ │ │ └── genres.ts # Tag filtering + weighting │ │ ├── ai/ -│ │ │ ├── client.ts # Anthropic client init -│ │ │ ├── context.ts # Build SonaUserContext from DB/cache -│ │ │ └── prompts.ts # Prompt templates for each feature -│ │ ├── db/ -│ │ │ ├── index.ts # Drizzle client (Neon serverless) -│ │ │ └── schema.ts # Table definitions -│ │ ├── crypto.ts # AES-256 encrypt/decrypt for tokens -│ │ ├── session.ts # iron-session config + type -│ │ └── utils.ts # cn() helper, misc utilities +│ │ │ ├── client.ts # Anthropic client +│ │ │ ├── context.ts # SonaUserContext builder +│ │ │ └── prompts.ts # Prompt templates +│ │ ├── db/{index.ts,schema.ts} +│ │ ├── crypto.ts # AES-256-GCM encrypt/decrypt +│ │ ├── session.ts # iron-session config + getSession() +│ │ └── utils.ts │ │ -│ ├── types/ -│ │ └── index.ts # Shared app types (SonaUserContext, etc.) -│ │ -│ └── middleware.ts # Rate limiting (future), logging -│ -├── drizzle/ -│ └── migrations/ # Versioned SQL migrations +│ └── types/index.ts # Shared types including LastFmTag │ +├── drizzle/ # Migration files ├── docs/ │ ├── PRD.md -│ └── SYSTEM_DESIGN.md # This document -│ -├── .github/ -│ └── workflows/ -│ └── ci.yml # Lint + type-check on every PR -│ -├── .env.local # Local secrets (gitignored) -├── .env.example # Template checked into repo -├── next.config.ts -├── drizzle.config.ts +│ └── SYSTEM_DESIGN.md +├── .github/workflows/ci.yml +├── .env.example └── README.md ``` --- -## 11. Environment Variables +## 10. Environment Variables ```bash -# .env.example - # Spotify OAuth SPOTIFY_CLIENT_ID= SPOTIFY_CLIENT_SECRET= -SPOTIFY_REDIRECT_URI=http://localhost:3000/api/auth/callback - -# Session encryption — generate with: -# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" -SESSION_SECRET= +SPOTIFY_REDIRECT_URI= -# Token encryption — separate key from session secret -TOKEN_ENCRYPTION_KEY= +# Session & token encryption +SESSION_SECRET= # min 32 chars, generate with crypto.randomBytes(32) +TOKEN_ENCRYPTION_KEY= # separate from session secret -# Neon Postgres -DATABASE_URL= +# Database +DATABASE_URL= # Neon connection string -# Anthropic +# AI ANTHROPIC_API_KEY= +# Genre data +LASTFM_API_KEY= # Free key from last.fm/api/account/create + +# Rate limiting (guest users) +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= + # App -NEXT_PUBLIC_APP_URL=http://localhost:3000 +NEXT_PUBLIC_APP_URL= ``` --- -## 12. Open Questions (Technical) +## 11. Open Questions (Technical) -- [ ] **Token encryption key rotation:** If `TOKEN_ENCRYPTION_KEY` needs to rotate, how do we re-encrypt existing tokens? Define a migration strategy before launch. -- [ ] **Spotify rate limits:** Spotify's API is rate-limited at ~180 requests per minute. The cache layer handles most of this, but aggressive user behavior (rapid time-range switching) could still hit limits. Consider per-user request throttling at the API route level. -- [ ] **Streaming AI responses:** Decide between Vercel AI SDK (`streamText`) vs raw Anthropic streaming API. Vercel AI SDK is easier to wire up; raw API gives more control over the stream format. -- [ ] **Chat history in v2:** When chat history is persisted, the schema will need a `chat_sessions` and `chat_messages` table. Design for this in schema even if not implemented in v1. -- [ ] **Prompt caching eligibility:** Anthropic prompt caching requires the cacheable prefix to be at least 1024 tokens. Verify the user context object consistently meets this threshold. +- [ ] **Streaming approach:** Vercel AI SDK (`streamText`) vs raw Anthropic streaming API for chat responses. Vercel AI SDK is easier; raw API gives more control. +- [ ] **Chat history persistence (v2):** When implemented, will need `chat_sessions` and `chat_messages` tables. Design schema now even if not implemented in v1. +- [ ] **Prompt caching eligibility:** Anthropic prompt caching requires cacheable prefix ≥ 1024 tokens. Verify SonaUserContext consistently meets this threshold. +- [ ] **Last.fm rate limits:** Last.fm asks developers to be reasonable with request frequency. Current implementation uses 100ms delay between artist tag lookups with a 20-artist cap. Monitor for any rate limit responses in production. +- [ ] **Spotify quota extension timing:** Extended Quota Mode requires 250k MAU. Apply after v1.0 launch once there's a live product to demonstrate. From c8c017bdcb8a5e1cca4f48696f3815218a587f4f Mon Sep 17 00:00:00 2001 From: lebuckman Date: Fri, 3 Apr 2026 12:39:11 -0700 Subject: [PATCH 4/9] feat: add TanStack Query provider and custom hooks - Add QueryClientProvider with optimized staleTime and gcTime defaults - Configure Google fonts in root layout - Add useTopTracks, useTopArtists, usePlaylists, useGenreBreakdown hooks - Add ReactQueryDevtools for development debugging --- src/app/globals.css | 5 +++++ src/app/layout.tsx | 36 ++++++++------------------------ src/components/providers.tsx | 29 +++++++++++++++++++++++++ src/hooks/use-genre-breakdown.ts | 18 ++++++++++++++++ src/hooks/use-playlists.ts | 15 +++++++++++++ src/hooks/use-top-artists.ts | 16 ++++++++++++++ src/hooks/use-top-tracks.ts | 16 ++++++++++++++ 7 files changed, 108 insertions(+), 27 deletions(-) create mode 100644 src/components/providers.tsx create mode 100644 src/hooks/use-genre-breakdown.ts create mode 100644 src/hooks/use-playlists.ts create mode 100644 src/hooks/use-top-artists.ts create mode 100644 src/hooks/use-top-tracks.ts diff --git a/src/app/globals.css b/src/app/globals.css index 3b4afe4..088e454 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -118,11 +118,16 @@ } @layer base { + :root { + --font-sans: "Instrument Sans", sans-serif; + --font-serif: "Fraunces", serif; + } * { @apply border-border outline-ring/50; } body { @apply bg-background text-foreground; + font-family: var(--font-sans); } html { @apply font-sans; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 37a4eba..ac405d1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,25 +1,16 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono, Instrument_Sans, Playfair_Display } from "next/font/google"; +import { Fraunces, Instrument_Sans } from "next/font/google"; import "./globals.css"; import { cn } from "@/lib/utils"; - -const playfairDisplayHeading = Playfair_Display({ subsets: ["latin"], variable: "--font-heading" }); +import { Providers } from "@/components/providers"; const instrumentSans = Instrument_Sans({ subsets: ["latin"], variable: "--font-sans" }); -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +const fraunces = Fraunces({ subsets: ["latin"], variable: "--font-serif", axes: ["opsz"] }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Sona — Understand your music", + description: "AI-powered music insights built on your Spotify listening history", }; export default function RootLayout({ @@ -28,19 +19,10 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - {children} + + + {children} + ); } diff --git a/src/components/providers.tsx b/src/components/providers.tsx new file mode 100644 index 0000000..e2eeb17 --- /dev/null +++ b/src/components/providers.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { useState } from "react"; + +export function Providers({ children }: { children: React.ReactNode }) { + // useState ensures each request gets its own QueryClient in SSR + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5 minutes before refetching + gcTime: 1000 * 60 * 60, // 1 hour in memory cache + retry: 1, // one retry on failure + refetchOnWindowFocus: false, // don't refetch when tab regains focus + }, + }, + }) + ); + + return ( + + {children} + {process.env.NODE_ENV === "development" && } + + ); +} diff --git a/src/hooks/use-genre-breakdown.ts b/src/hooks/use-genre-breakdown.ts new file mode 100644 index 0000000..93e5255 --- /dev/null +++ b/src/hooks/use-genre-breakdown.ts @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; +import type { GenreBreakdownResponse } from "@/app/api/lastfm/genre-breakdown/route"; + +async function fetchGenreBreakdown(): Promise { + const res = await fetch("/api/lastfm/genre-breakdown"); + if (!res.ok) throw new Error("Failed to fetch genre breakdown"); + return res.json() as Promise; +} + +export function useGenreBreakdown() { + return useQuery({ + queryKey: ["genre-breakdown"], + queryFn: fetchGenreBreakdown, + // Genre breakdown depends on top artists being cached first. + // staleTime is longer since we cache this for 7 days server-side. + staleTime: 1000 * 60 * 60 * 24, // 24 hours client-side + }); +} diff --git a/src/hooks/use-playlists.ts b/src/hooks/use-playlists.ts new file mode 100644 index 0000000..2097d18 --- /dev/null +++ b/src/hooks/use-playlists.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; +import type { PlaylistsResponse } from "@/app/api/spotify/playlists/route"; + +async function fetchPlaylists(): Promise { + const res = await fetch("/api/spotify/playlists"); + if (!res.ok) throw new Error("Failed to fetch playlists"); + return res.json() as Promise; +} + +export function usePlaylists() { + return useQuery({ + queryKey: ["playlists"], + queryFn: fetchPlaylists, + }); +} diff --git a/src/hooks/use-top-artists.ts b/src/hooks/use-top-artists.ts new file mode 100644 index 0000000..070705b --- /dev/null +++ b/src/hooks/use-top-artists.ts @@ -0,0 +1,16 @@ +import { useQuery } from "@tanstack/react-query"; +import type { TimeRange } from "@/types"; +import type { TopArtistsResponse } from "@/app/api/spotify/top-artists/route"; + +async function fetchTopArtists(range: TimeRange): Promise { + const res = await fetch(`/api/spotify/top-artists?range=${range}`); + if (!res.ok) throw new Error("Failed to fetch top artists"); + return res.json() as Promise; +} + +export function useTopArtists(timeRange: TimeRange = "short_term") { + return useQuery({ + queryKey: ["top-artists", timeRange], + queryFn: () => fetchTopArtists(timeRange), + }); +} diff --git a/src/hooks/use-top-tracks.ts b/src/hooks/use-top-tracks.ts new file mode 100644 index 0000000..1b8ee75 --- /dev/null +++ b/src/hooks/use-top-tracks.ts @@ -0,0 +1,16 @@ +import { useQuery } from "@tanstack/react-query"; +import type { TimeRange } from "@/types"; +import type { TopTracksResponse } from "@/app/api/spotify/top-tracks/route"; + +async function fetchTopTracks(range: TimeRange): Promise { + const res = await fetch(`/api/spotify/top-tracks?range=${range}`); + if (!res.ok) throw new Error("Failed to fetch top tracks"); + return res.json() as Promise; +} + +export function useTopTracks(timeRange: TimeRange = "short_term") { + return useQuery({ + queryKey: ["top-tracks", timeRange], + queryFn: () => fetchTopTracks(timeRange), + }); +} From ec9f82a6c1fe62838b2a504f5db4bcef3ee71f58 Mon Sep 17 00:00:00 2001 From: lebuckman Date: Fri, 3 Apr 2026 12:40:21 -0700 Subject: [PATCH 5/9] feat(profile): build up profile page with API data - Add SonaVoice and SectionLabel shared components - Build ArtistsSection with editorial top-3 grid and time range selector - Build TracksSection with ranked list, album art, and duration formatting - Build GenreSection with bars from Last.fm genre breakdown - Build PlaylistsSection with 6-up grid layout - Wire profile page with sticky nav, identity hero, and section anchors - Configure next/image remote patterns for Spotify CDN domains - Fix LCP warning with priority loading on first artist image --- next.config.ts | 21 ++- .../profile/_components/artists-section.tsx | 132 ++++++++++++++++++ .../profile/_components/genre-section.tsx | 65 +++++++++ .../profile/_components/playlists-section.tsx | 59 ++++++++ .../profile/_components/tracks-section.tsx | 115 +++++++++++++++ src/app/(protected)/profile/page.tsx | 82 +++++++++-- src/components/layout/section-label.tsx | 19 +++ src/components/sona/sona-voice.tsx | 16 +++ 8 files changed, 496 insertions(+), 13 deletions(-) create mode 100644 src/app/(protected)/profile/_components/artists-section.tsx create mode 100644 src/app/(protected)/profile/_components/genre-section.tsx create mode 100644 src/app/(protected)/profile/_components/playlists-section.tsx create mode 100644 src/app/(protected)/profile/_components/tracks-section.tsx create mode 100644 src/components/layout/section-label.tsx create mode 100644 src/components/sona/sona-voice.tsx diff --git a/next.config.ts b/next.config.ts index e9ffa30..00593c6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,26 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "i.scdn.co", // Spotify artist/album images + }, + { + protocol: "https", + hostname: "image-cdn-ak.spotifycdn.com", // Spotify playlist images + }, + { + protocol: "https", + hostname: "image-cdn-fa.spotifycdn.com", // Spotify playlist images + }, + { + protocol: "https", + hostname: "mosaic.scdn.co", // Spotify mosaic playlist covers + }, + ], + }, }; export default nextConfig; diff --git a/src/app/(protected)/profile/_components/artists-section.tsx b/src/app/(protected)/profile/_components/artists-section.tsx new file mode 100644 index 0000000..3a3b175 --- /dev/null +++ b/src/app/(protected)/profile/_components/artists-section.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; +import { useTopArtists } from "@/hooks/use-top-artists"; +import { SectionLabel } from "@/components/layout/section-label"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; +import type { TimeRange } from "@/types"; + +const TIME_RANGES: { label: string; value: TimeRange }[] = [ + { label: "4 Weeks", value: "short_term" }, + { label: "6 Months", value: "medium_term" }, + { label: "All Time", value: "long_term" }, +]; + +export function ArtistsSection() { + const [timeRange, setTimeRange] = useState("short_term"); + const { data, isLoading } = useTopArtists(timeRange); + + const topThree = data?.artists.slice(0, 3) ?? []; + const rest = data?.artists.slice(3, 9) ?? []; + + return ( +
+
+ Defining Voices +
+

+ Top Artists +

+
+ {TIME_RANGES.map((r) => ( + + ))} +
+
+
+ + {/* Top 3 editorial grid */} + {isLoading ? ( +
+ {[...Array(3)].map((_, i) => ( + + ))} +
+ ) : ( +
+ {topThree.map((artist, i) => ( + + {artist.imageUrl ? ( + {artist.name} + ) : ( +
🎵
+ )} +
+ )} + + {/* Remaining artists list */} + {!isLoading && rest.length > 0 && ( + + )} +
+ ); +} diff --git a/src/app/(protected)/profile/_components/genre-section.tsx b/src/app/(protected)/profile/_components/genre-section.tsx new file mode 100644 index 0000000..2d36861 --- /dev/null +++ b/src/app/(protected)/profile/_components/genre-section.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useGenreBreakdown } from "@/hooks/use-genre-breakdown"; +import { useTopArtists } from "@/hooks/use-top-artists"; +import { SectionLabel } from "@/components/layout/section-label"; +import { Skeleton } from "@/components/ui/skeleton"; + +export function GenreSection() { + // Fetch top artists first to warm the cache that genre breakdown depends on + useTopArtists("short_term"); + const { data, isLoading } = useGenreBreakdown(); + + const genres = data?.genres ?? []; + + return ( +
+ Sound DNA +

+ Your Genre Fingerprint +

+ + {isLoading ? ( +
+ {[...Array(6)].map((_, i) => ( +
+ + + +
+ ))} +
+ ) : genres.length === 0 ? ( +

+ Genre data is loading — check back in a moment. +

+ ) : ( +
+ {genres.map((entry) => ( +
+ + {entry.genre} + +
+
+
+ + {entry.weight}% + +
+ ))} +
+ )} +
+ ); +} diff --git a/src/app/(protected)/profile/_components/playlists-section.tsx b/src/app/(protected)/profile/_components/playlists-section.tsx new file mode 100644 index 0000000..bb6720e --- /dev/null +++ b/src/app/(protected)/profile/_components/playlists-section.tsx @@ -0,0 +1,59 @@ +"use client"; + +import Image from "next/image"; +import { usePlaylists } from "@/hooks/use-playlists"; +import { SectionLabel } from "@/components/layout/section-label"; +import { Skeleton } from "@/components/ui/skeleton"; + +export function PlaylistsSection() { + const { data, isLoading } = usePlaylists(); + const playlists = data?.playlists.slice(0, 6) ?? []; + + return ( +
+ Your Collections +

+ Playlists +

+ +
+ {isLoading + ? [...Array(6)].map((_, i) => ( +
+ + + +
+ )) + : playlists.map((playlist) => ( + +
+ {playlist.imageUrl ? ( + {playlist.name} + ) : ( +
+ 🎵 +
+ )} +
+

{playlist.name}

+

{playlist.trackCount} tracks

+
+ ))} +
+
+ ); +} diff --git a/src/app/(protected)/profile/_components/tracks-section.tsx b/src/app/(protected)/profile/_components/tracks-section.tsx new file mode 100644 index 0000000..1ba06bb --- /dev/null +++ b/src/app/(protected)/profile/_components/tracks-section.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; +import { useTopTracks } from "@/hooks/use-top-tracks"; +import { SectionLabel } from "@/components/layout/section-label"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; +import type { TimeRange } from "@/types"; + +const TIME_RANGES: { label: string; value: TimeRange }[] = [ + { label: "4 Weeks", value: "short_term" }, + { label: "6 Months", value: "medium_term" }, + { label: "All Time", value: "long_term" }, +]; + +function formatDuration(ms: number): string { + const minutes = Math.floor(ms / 60000); + const seconds = Math.floor((ms % 60000) / 1000); + return `${minutes}:${seconds.toString().padStart(2, "0")}`; +} + +export function TracksSection() { + const [timeRange, setTimeRange] = useState("short_term"); + const { data, isLoading } = useTopTracks(timeRange); + + const tracks = data?.tracks.slice(0, 10) ?? []; + + return ( +
+
+ This Month +
+

+ Top Tracks +

+
+ {TIME_RANGES.map((r) => ( + + ))} +
+
+
+ + +
+ ); +} diff --git a/src/app/(protected)/profile/page.tsx b/src/app/(protected)/profile/page.tsx index 6b04daf..cc78805 100644 --- a/src/app/(protected)/profile/page.tsx +++ b/src/app/(protected)/profile/page.tsx @@ -1,20 +1,78 @@ import { getSession } from "@/lib/session"; +import { ArtistsSection } from "./_components/artists-section"; +import { TracksSection } from "./_components/tracks-section"; +import { GenreSection } from "./_components/genre-section"; +import { PlaylistsSection } from "./_components/playlists-section"; +import { SectionLabel } from "@/components/layout/section-label"; +import { SonaVoice } from "@/components/sona/sona-voice"; export default async function ProfilePage() { const session = await getSession(); return ( -
-

Hey, {session.displayName ?? "there"} 👋

-

Successful Authentication.

-
- -
-
+
+ {/* Nav */} +
+
+ sona + +
+ +
+
+
+ +
+ {/* Identity hero */} +
+ Your Identity +

+ {session.displayName ?? "Your"}'s +
+ music story. +

+ + This is what your listening looks like — your artists, your sound, your playlists. Sona + reads between the lines so you don't have to. + +
+ +
+ + +
+ + +
+ + +
+ +
+
); } diff --git a/src/components/layout/section-label.tsx b/src/components/layout/section-label.tsx new file mode 100644 index 0000000..b310923 --- /dev/null +++ b/src/components/layout/section-label.tsx @@ -0,0 +1,19 @@ +import { cn } from "@/lib/utils"; + +interface SectionLabelProps { + children: React.ReactNode; + className?: string; +} + +export function SectionLabel({ children, className }: SectionLabelProps) { + return ( +

+ {children} +

+ ); +} diff --git a/src/components/sona/sona-voice.tsx b/src/components/sona/sona-voice.tsx new file mode 100644 index 0000000..b773a37 --- /dev/null +++ b/src/components/sona/sona-voice.tsx @@ -0,0 +1,16 @@ +import { cn } from "@/lib/utils"; + +interface SonaVoiceProps { + children: React.ReactNode; + className?: string; +} + +export function SonaVoice({ children, className }: SonaVoiceProps) { + return ( +

+ {children} +

+ ); +} From d25f9e6284161a74580cdc421d09320c1a72221c Mon Sep 17 00:00:00 2001 From: lebuckman Date: Fri, 3 Apr 2026 13:14:07 -0700 Subject: [PATCH 6/9] fix: address major CodeRabbit issues - Gate useGenreBreakdown on useTopArtists success to prevent race condition - Add SPOTIFY_RATE_LIMITED handling to playlists route for consistency - Harden Last.fm fetchArtistTopTags with try/catch and API key guard --- .../profile/_components/genre-section.tsx | 8 ++++-- src/app/api/spotify/playlists/route.ts | 3 ++ src/hooks/use-genre-breakdown.ts | 3 +- src/lib/lastfm/client.ts | 28 +++++++++---------- 4 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/app/(protected)/profile/_components/genre-section.tsx b/src/app/(protected)/profile/_components/genre-section.tsx index 2d36861..6631bd0 100644 --- a/src/app/(protected)/profile/_components/genre-section.tsx +++ b/src/app/(protected)/profile/_components/genre-section.tsx @@ -6,9 +6,11 @@ import { SectionLabel } from "@/components/layout/section-label"; import { Skeleton } from "@/components/ui/skeleton"; export function GenreSection() { - // Fetch top artists first to warm the cache that genre breakdown depends on - useTopArtists("short_term"); - const { data, isLoading } = useGenreBreakdown(); + // Only fetch genre breakdown after top artists have loaded + const topArtistsQuery = useTopArtists("short_term"); + const { data, isLoading } = useGenreBreakdown({ + enabled: topArtistsQuery.isSuccess, + }); const genres = data?.genres ?? []; diff --git a/src/app/api/spotify/playlists/route.ts b/src/app/api/spotify/playlists/route.ts index 8ecd230..3ba3331 100644 --- a/src/app/api/spotify/playlists/route.ts +++ b/src/app/api/spotify/playlists/route.ts @@ -54,6 +54,9 @@ export async function GET() { if (error instanceof Error && error.message === "SPOTIFY_UNAUTHORIZED") { return NextResponse.json({ error: "Spotify session expired" }, { status: 401 }); } + if (error instanceof Error && error.message.startsWith("SPOTIFY_RATE_LIMIT")) { + return NextResponse.json({ error: "Rate limited by Spotify" }, { status: 429 }); + } console.error("Playlists error:", error); return NextResponse.json({ error: "Failed to fetch playlists" }, { status: 500 }); } diff --git a/src/hooks/use-genre-breakdown.ts b/src/hooks/use-genre-breakdown.ts index 93e5255..c106af6 100644 --- a/src/hooks/use-genre-breakdown.ts +++ b/src/hooks/use-genre-breakdown.ts @@ -7,10 +7,11 @@ async function fetchGenreBreakdown(): Promise { return res.json() as Promise; } -export function useGenreBreakdown() { +export function useGenreBreakdown(options: { enabled?: boolean }) { return useQuery({ queryKey: ["genre-breakdown"], queryFn: fetchGenreBreakdown, + enabled: options?.enabled ?? true, // Genre breakdown depends on top artists being cached first. // staleTime is longer since we cache this for 7 days server-side. staleTime: 1000 * 60 * 60 * 24, // 24 hours client-side diff --git a/src/lib/lastfm/client.ts b/src/lib/lastfm/client.ts index c507df2..a610627 100644 --- a/src/lib/lastfm/client.ts +++ b/src/lib/lastfm/client.ts @@ -6,30 +6,30 @@ import type { LastFmTag, LastFmTagsResponse } from "@/types"; const LASTFM_BASE = "https://ws.audioscrobbler.com/2.0/"; export async function fetchArtistTopTags(artistName: string): Promise { + const apiKey = process.env.LASTFM_API_KEY; + if (!apiKey) return []; + const params = new URLSearchParams({ method: "artist.gettoptags", artist: artistName, - api_key: process.env.LASTFM_API_KEY as string, + api_key: apiKey, format: "json", autocorrect: "1", // Last.fm will correct minor spelling differences }); - const response = await fetch(`${LASTFM_BASE}?${params.toString()}`, { - next: { revalidate: 0 }, - }); - - if (!response.ok) { + try { + const response = await fetch(`${LASTFM_BASE}?${params.toString()}`, { + next: { revalidate: 0 }, + }); // Some artists aren't in Last.fm's database. - // Return empty array and let the caller handle it gracefully. - return []; - } + if (!response.ok) return []; - const data = (await response.json()) as LastFmTagsResponse | { error: number; message: string }; + const data = (await response.json()) as LastFmTagsResponse | { error: number; message: string }; + // Last.fm returns a 200 with an error field for unknown artists + if (!data || typeof data !== "object" || "error" in data) return []; - // Last.fm returns a 200 with an error field for unknown artists - if ("error" in data) { + return data.toptags.tag ?? []; + } catch (error) { return []; } - - return data.toptags.tag ?? []; } From 961a5fb5dedc44f550e9070002f7d90a08de9726 Mon Sep 17 00:00:00 2001 From: lebuckman Date: Fri, 3 Apr 2026 13:29:08 -0700 Subject: [PATCH 7/9] fix: address minor CodeRabbit issues - Remove deprecated audio features endpoint - Refactor TIME_RANGES constant - Semantic cleanup in profile page and components --- .../profile/_components/artists-section.tsx | 7 +------ .../profile/_components/tracks-section.tsx | 13 ++++++------ src/app/(protected)/profile/page.tsx | 3 ++- src/app/api/lastfm/genre-breakdown/route.ts | 2 +- src/lib/constants.ts | 7 +++++++ src/lib/spotify/client.ts | 21 +------------------ src/types/index.ts | 12 ----------- 7 files changed, 19 insertions(+), 46 deletions(-) create mode 100644 src/lib/constants.ts diff --git a/src/app/(protected)/profile/_components/artists-section.tsx b/src/app/(protected)/profile/_components/artists-section.tsx index 3a3b175..5255251 100644 --- a/src/app/(protected)/profile/_components/artists-section.tsx +++ b/src/app/(protected)/profile/_components/artists-section.tsx @@ -6,14 +6,9 @@ import { useTopArtists } from "@/hooks/use-top-artists"; import { SectionLabel } from "@/components/layout/section-label"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; +import { TIME_RANGES } from "@/lib/constants"; import type { TimeRange } from "@/types"; -const TIME_RANGES: { label: string; value: TimeRange }[] = [ - { label: "4 Weeks", value: "short_term" }, - { label: "6 Months", value: "medium_term" }, - { label: "All Time", value: "long_term" }, -]; - export function ArtistsSection() { const [timeRange, setTimeRange] = useState("short_term"); const { data, isLoading } = useTopArtists(timeRange); diff --git a/src/app/(protected)/profile/_components/tracks-section.tsx b/src/app/(protected)/profile/_components/tracks-section.tsx index 1ba06bb..ac12266 100644 --- a/src/app/(protected)/profile/_components/tracks-section.tsx +++ b/src/app/(protected)/profile/_components/tracks-section.tsx @@ -6,13 +6,14 @@ import { useTopTracks } from "@/hooks/use-top-tracks"; import { SectionLabel } from "@/components/layout/section-label"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; +import { TIME_RANGES } from "@/lib/constants"; import type { TimeRange } from "@/types"; -const TIME_RANGES: { label: string; value: TimeRange }[] = [ - { label: "4 Weeks", value: "short_term" }, - { label: "6 Months", value: "medium_term" }, - { label: "All Time", value: "long_term" }, -]; +const TIME_RANGE_LABELS: Record = { + short_term: "Past Month", + medium_term: "Past 6 Months", + long_term: "All Time", +}; function formatDuration(ms: number): string { const minutes = Math.floor(ms / 60000); @@ -29,7 +30,7 @@ export function TracksSection() { return (
- This Month + {TIME_RANGE_LABELS[timeRange]}

Top Tracks diff --git a/src/app/(protected)/profile/page.tsx b/src/app/(protected)/profile/page.tsx index cc78805..4769d02 100644 --- a/src/app/(protected)/profile/page.tsx +++ b/src/app/(protected)/profile/page.tsx @@ -8,6 +8,7 @@ import { SonaVoice } from "@/components/sona/sona-voice"; export default async function ProfilePage() { const session = await getSession(); + const ownerLabel = session.displayName ? `${session.displayName}'s` : "Your"; return (
@@ -51,7 +52,7 @@ export default async function ProfilePage() { id="identity-heading" className="mb-4 font-serif text-5xl font-medium italic tracking-tight sm:text-6xl" > - {session.displayName ?? "Your"}'s + {ownerLabel}
music story.

diff --git a/src/app/api/lastfm/genre-breakdown/route.ts b/src/app/api/lastfm/genre-breakdown/route.ts index fc43d4f..8707c8b 100644 --- a/src/app/api/lastfm/genre-breakdown/route.ts +++ b/src/app/api/lastfm/genre-breakdown/route.ts @@ -23,7 +23,7 @@ export async function GET() { } try { - // 1Check genre breakdown cache first (24hr TTL) + // Check genre breakdown cache first (24hr TTL) const cached = await getCached(session.userId, "genre_breakdown"); if (cached) { return NextResponse.json({ genres: cached, cached: true } satisfies GenreBreakdownResponse); diff --git a/src/lib/constants.ts b/src/lib/constants.ts new file mode 100644 index 0000000..1401616 --- /dev/null +++ b/src/lib/constants.ts @@ -0,0 +1,7 @@ +import type { TimeRange } from "@/types"; + +export const TIME_RANGES: { label: string; value: TimeRange }[] = [ + { label: "4 Weeks", value: "short_term" }, + { label: "6 Months", value: "medium_term" }, + { label: "All Time", value: "long_term" }, +]; diff --git a/src/lib/spotify/client.ts b/src/lib/spotify/client.ts index 4da521a..db979fe 100644 --- a/src/lib/spotify/client.ts +++ b/src/lib/spotify/client.ts @@ -3,13 +3,7 @@ // Token management (refresh, encryption) is handled by withValidToken() // before these functions are ever called. -import type { - SpotifyTrack, - SpotifyArtist, - AudioFeatures, - SpotifyPlaylist, - TimeRange, -} from "@/types"; +import type { SpotifyTrack, SpotifyArtist, SpotifyPlaylist, TimeRange } from "@/types"; const SPOTIFY_API_BASE = "https://api.spotify.com/v1"; @@ -70,19 +64,6 @@ export async function fetchTopArtists(accessToken: string, timeRange: TimeRange, ); } -// ── Audio Features ─────────────────────────────────────────────────────────── - -interface SpotifyAudioFeaturesResponse { - audio_features: AudioFeatures[]; -} - -export async function fetchAudioFeatures(accessToken: string, trackIds: string[]) { - return spotifyFetch( - `/audio-features?ids=${trackIds.join(",")}`, - accessToken - ); -} - // ── Playlists ──────────────────────────────────────────────────────────────── interface SpotifyPlaylistsResponse { diff --git a/src/types/index.ts b/src/types/index.ts index 420efd0..4f2d7f6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -26,18 +26,6 @@ export interface SpotifyPlaylist { external_urls: { spotify: string }; } -export interface AudioFeatures { - id: string; - energy: number; - danceability: number; - valence: number; - acousticness: number; - instrumentalness: number; - tempo: number; - loudness: number; - speechiness: number; -} - export interface LastFmTag { name: string; count: number; From ed7ac9b9649e22ced049f115d7e366166b2d1a40 Mon Sep 17 00:00:00 2001 From: lebuckman Date: Fri, 3 Apr 2026 13:38:47 -0700 Subject: [PATCH 8/9] perf: batch parallel requests to Last.fm --- src/app/api/lastfm/genre-breakdown/route.ts | 26 ++++++++++++++------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/app/api/lastfm/genre-breakdown/route.ts b/src/app/api/lastfm/genre-breakdown/route.ts index 8707c8b..7614669 100644 --- a/src/app/api/lastfm/genre-breakdown/route.ts +++ b/src/app/api/lastfm/genre-breakdown/route.ts @@ -43,16 +43,26 @@ export async function GET() { const artistsToQuery = cachedArtists.slice(0, 20); const artistTags: { artistRank: number; artistName: string; tags: LastFmTag[] }[] = []; - for (let i = 0; i < artistsToQuery.length; i++) { - const artist = artistsToQuery[i]; - if (!artist) continue; + // Process in batches of 5 concurrent requests + const BATCH_SIZE = 5; + for (let batch = 0; batch < artistsToQuery.length; batch += BATCH_SIZE) { + const batchArtists = artistsToQuery.slice(batch, batch + BATCH_SIZE); + const batchResults = await Promise.all( + batchArtists.map(async (artist, batchIdx) => { + const tags = await fetchArtistTopTags(artist.name); + return { + artistRank: batch + batchIdx + 1, + artistName: artist.name, + tags, + }; + }) + ); - const tags = await fetchArtistTopTags(artist.name); - artistTags.push({ artistRank: i + 1, artistName: artist.name, tags }); + artistTags.push(...batchResults); - // Small delay to avoid rate limiting (Last.fm terms) - if (i < artistsToQuery.length - 1) { - await new Promise((resolve) => setTimeout(resolve, 100)); + // Small delay to respect rate limiting (Last.fm terms) + if (batch + BATCH_SIZE < artistsToQuery.length) { + await new Promise((resolve) => setTimeout(resolve, 200)); } } From 2997e3470925b14ad55028b432d009c826ea43e7 Mon Sep 17 00:00:00 2001 From: lebuckman Date: Fri, 3 Apr 2026 13:54:06 -0700 Subject: [PATCH 9/9] fix: remove unused error variable --- src/lib/lastfm/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/lastfm/client.ts b/src/lib/lastfm/client.ts index a610627..5efcd71 100644 --- a/src/lib/lastfm/client.ts +++ b/src/lib/lastfm/client.ts @@ -29,7 +29,7 @@ export async function fetchArtistTopTags(artistName: string): Promise