import { Router } from 'express'; import { db } from '../lib/db.js'; import axios from 'axios'; import { requireAuth } from '../middleware/auth.js'; import { syncUserData } from '../lib/sync.js'; import { ensureValidAccessToken } from '../lib/spotify.js'; // Helper functions for unique categories function calculateLongestSession(tracks) { if (tracks.length === 0) return { duration: 0, startTime: 0, endTime: 0 }; const sortedTracks = tracks.sort((a, b) => a.played_at - b.played_at); let longestSession = { duration: 0, startTime: 0, endTime: 0 }; let currentSession = { startTime: sortedTracks[0]?.played_at || 0, endTime: sortedTracks[0]?.played_at || 0 }; for (let i = 1; i < sortedTracks.length; i++) { const timeDiff = sortedTracks[i].played_at - sortedTracks[i - 1].played_at; if (timeDiff <= 30 * 60 * 1000) { // 30 minutes gap currentSession.endTime = sortedTracks[i].played_at; } else { const sessionDuration = currentSession.endTime - currentSession.startTime; if (sessionDuration > longestSession.duration) { longestSession = { ...currentSession, duration: sessionDuration }; } currentSession = { startTime: sortedTracks[i].played_at, endTime: sortedTracks[i].played_at }; } } // Check the final session const finalSessionDuration = currentSession.endTime - currentSession.startTime; if (finalSessionDuration > longestSession.duration) { longestSession = { ...currentSession, duration: finalSessionDuration }; } // If no session is longer than 0, use the first track as a 1-minute session if (longestSession.duration === 0 && tracks.length > 0) { const firstTrack = sortedTracks[0]; longestSession = { startTime: firstTrack.played_at, endTime: firstTrack.played_at + 60000, // 1 minute duration: 60000 }; } return longestSession; } function calculateMostDiverseDay(tracks) { const dayGroups = tracks.reduce((acc, track) => { const date = new Date(track.played_at).toDateString(); if (!acc[date]) acc[date] = new Set(); track.artists?.forEach((artist) => acc[date].add(artist.id)); return acc; }, {}); let mostDiverse = { date: '', artistCount: 0 }; Object.entries(dayGroups).forEach(([date, artists]) => { if (artists.size > mostDiverse.artistCount) { mostDiverse = { date, artistCount: artists.size }; } }); return mostDiverse; } function getMostActiveMonth(tracks) { const monthGroups = tracks.reduce((acc, track) => { const date = new Date(track.played_at); const monthKey = `${date.getFullYear()}-${date.getMonth() + 1}`; acc[monthKey] = (acc[monthKey] || 0) + 1; return acc; }, {}); let mostActive = { month: '', count: 0 }; Object.entries(monthGroups).forEach(([month, count]) => { if (count > mostActive.count) { mostActive = { month, count: count }; } }); return mostActive; } function calculateListeningVelocity(tracks, timeRange) { if (tracks.length === 0) return 0; let daysInRange = 1; if (timeRange === 'year') daysInRange = 365; else if (timeRange === 'month') daysInRange = 30; else if (timeRange === 'week') daysInRange = 7; else { const firstTrack = Math.min(...tracks.map(t => t.played_at)); const lastTrack = Math.max(...tracks.map(t => t.played_at)); daysInRange = Math.max(1, (lastTrack - firstTrack) / (1000 * 60 * 60 * 24)); } return tracks.length / daysInRange; } export const usersRouter = Router(); // Fetch and store user data (recently played, top tracks/artists) usersRouter.post('/:uid/sync', requireAuth, async (req, res) => { try { const { uid } = req.params; if (!req.user || req.user.uid !== uid) return res.status(403).json({ error: 'Forbidden' }); await syncUserData(uid); res.json({ ok: true }); } catch (err) { console.error(err.response?.data || err.message); res.status(500).json({ error: 'Sync failed' }); } }); usersRouter.get('/:uid', (req, res) => { const { uid } = req.params; const user = db.db.prepare('SELECT id, display_name, email, avatar_url, country, product, created_at, updated_at, last_synced_at FROM users WHERE id = ?').get(uid); if (!user) return res.status(404).json({ error: 'Not found' }); res.json(user); }); // Simple search by display_name (substring, case-insensitive) usersRouter.get('/search/q', (req, res) => { const q = String(req.query.q || '').trim(); if (!q) return res.json([]); const rows = db.db .prepare('SELECT id, display_name, avatar_url FROM users WHERE lower(display_name) LIKE ? LIMIT 20') .all(`%${q.toLowerCase()}%`); res.json(rows); }); usersRouter.get('/:uid/recently-played', (req, res) => { const { uid } = req.params; const rows = db.db.prepare('SELECT id, user_id, played_at, track_json FROM recently_played WHERE user_id = ? ORDER BY played_at DESC LIMIT 50').all(uid); res.json(rows.map((r) => ({ id: r.id, user_id: r.user_id, played_at: r.played_at, track: JSON.parse(r.track_json) }))); }); usersRouter.get('/:uid/top-tracks', (req, res) => { const { uid } = req.params; const range = req.query.time_range || 'short_term'; const rows = db.db.prepare('SELECT rank, track_json FROM top_tracks WHERE user_id = ? AND time_range = ? ORDER BY rank ASC').all(uid, range); res.json(rows.map((r) => ({ rank: r.rank, track: JSON.parse(r.track_json) }))); }); usersRouter.get('/:uid/top-artists', (req, res) => { const { uid } = req.params; const range = req.query.time_range || 'short_term'; const rows = db.db.prepare('SELECT rank, artist_json FROM top_artists WHERE user_id = ? AND time_range = ? ORDER BY rank ASC').all(uid, range); res.json(rows.map((r) => ({ rank: r.rank, artist: JSON.parse(r.artist_json) }))); }); // status: last sync time and next scheduled time (based on 10 min interval) usersRouter.get('/:uid/status', (req, res) => { const { uid } = req.params; const row = db.db.prepare('SELECT last_synced_at FROM users WHERE id = ?').get(uid); const last = row?.last_synced_at || null; // Match backend full sync interval (2 minutes) const next = last ? last + 2 * 60 * 1000 : null; res.json({ lastSyncedAt: last, nextSyncAt: next }); }); // Now playing (with token refresh) usersRouter.get('/:uid/now-playing', requireAuth, async (req, res) => { try { const { uid } = req.params; if (!req.user) return res.status(403).json({ error: 'Forbidden' }); if (req.user.uid !== uid) { // allow if users are partnered const paired = db.db.prepare('SELECT 1 FROM friendships WHERE (user_a_id=? AND user_b_id=?) OR (user_a_id=? AND user_b_id=?)').get(req.user.uid, uid, uid, req.user.uid); if (!paired) return res.status(403).json({ error: 'Forbidden' }); } // Try DB-captured snapshot first (last 60s) const recent = db.db.prepare('SELECT played_at, track_json FROM recently_played WHERE user_id=? ORDER BY played_at DESC LIMIT 1').get(uid); if (recent?.played_at && Date.now() - recent.played_at < 60000 && recent?.track_json) { try { const item = JSON.parse(recent.track_json); return res.json({ is_playing: true, item, source: 'db' }); } catch { } } const accessToken = await ensureValidAccessToken(uid); const resp = await axios.get('https://api.spotify.com/v1/me/player/currently-playing?additional_types=track', { headers: { Authorization: `Bearer ${accessToken}` }, validateStatus: () => true, }); if (resp.status === 204) return res.json({ is_playing: false }); if (resp.status >= 400) { // Return a benign payload rather than 500 to avoid breaking UI when Spotify returns errors return res.json({ is_playing: false }); } return res.status(200).json(resp.data); } catch (e) { res.status(200).json({ is_playing: false }); } }); // Audio features bulk usersRouter.get('/:uid/audio-features', requireAuth, async (req, res) => { try { const { uid } = req.params; const ids = String(req.query.ids || '').split(',').map(s => s.trim()).filter(Boolean); if (!req.user) return res.status(403).json({ error: 'Forbidden' }); if (req.user.uid !== uid) { const paired = db.db.prepare('SELECT 1 FROM friendships WHERE (user_a_id=? AND user_b_id=?) OR (user_a_id=? AND user_b_id=?)').get(req.user.uid, uid, uid, req.user.uid); if (!paired) return res.status(403).json({ error: 'Forbidden' }); } if (!ids.length) return res.json({ audio_features: [] }); const accessToken = await ensureValidAccessToken(uid); // Some tracks may be local or missing features; handle gracefully const resp = await axios.get('https://api.spotify.com/v1/audio-features', { headers: { Authorization: `Bearer ${accessToken}` }, params: { ids: ids.slice(0, 100).join(',') }, }); res.json({ audio_features: Array.isArray(resp.data?.audio_features) ? resp.data.audio_features.filter(Boolean) : [] }); } catch (e) { res.status(500).json({ error: 'Failed to fetch audio features', detail: e?.response?.data || e?.message }); } }); // Wrapped feature - generate personalized music statistics usersRouter.get('/:uid/wrapped', requireAuth, async (req, res) => { try { const { uid } = req.params; if (!req.user) return res.status(403).json({ error: 'Forbidden' }); if (req.user.uid !== uid) { const paired = db.db.prepare('SELECT 1 FROM friendships WHERE (user_a_id=? AND user_b_id=?) OR (user_a_id=? AND user_b_id=?)').get(req.user.uid, uid, uid, req.user.uid); if (!paired) return res.status(403).json({ error: 'Forbidden' }); } // Get time range from query (default to all time) const timeRange = req.query.range || 'all'; let timeFilter = ''; let timeParams = [uid]; if (timeRange === 'year') { const oneYearAgo = Date.now() - (365 * 24 * 60 * 60 * 1000); timeFilter = 'AND played_at >= ?'; timeParams.push(oneYearAgo); } else if (timeRange === 'month') { const oneMonthAgo = Date.now() - (30 * 24 * 60 * 60 * 1000); timeFilter = 'AND played_at >= ?'; timeParams.push(oneMonthAgo); } else if (timeRange === 'week') { const oneWeekAgo = Date.now() - (7 * 24 * 60 * 60 * 1000); timeFilter = 'AND played_at >= ?'; timeParams.push(oneWeekAgo); } // Get all played tracks for analysis const playedTracks = db.db.prepare(` SELECT played_at, track_json FROM recently_played WHERE user_id = ? ${timeFilter} ORDER BY played_at DESC `).all(...timeParams); if (playedTracks.length === 0) { return res.json({ totalTracks: 0, totalListeningTime: 0, topSongs: [], topArtists: [], topGenres: [], listeningPatterns: { mostActiveHour: 0, mostActiveDay: 'Monday', listeningStreak: 0 }, timeRange, generatedAt: Date.now() }); } // Parse tracks and calculate statistics const tracks = playedTracks.map(row => { const track = JSON.parse(row.track_json); return { ...track, played_at: row.played_at, duration_ms: track.duration_ms || 0 }; }); // Calculate total listening time const totalListeningTime = tracks.reduce((sum, track) => sum + (track.duration_ms || 0), 0); // Top songs (by play count) const songCounts = new Map(); tracks.forEach(track => { const key = track.id; if (songCounts.has(key)) { const existing = songCounts.get(key); existing.count++; existing.totalDuration += track.duration_ms || 0; } else { songCounts.set(key, { track, count: 1, totalDuration: track.duration_ms || 0 }); } }); const topSongs = Array.from(songCounts.values()) .sort((a, b) => b.count - a.count) .slice(0, 10) .map(item => ({ ...item.track, playCount: item.count, totalDuration: item.totalDuration })); // Top artists (by play count) const artistCounts = new Map(); tracks.forEach(track => { track.artists?.forEach((artist) => { const key = artist.id; if (artistCounts.has(key)) { const existing = artistCounts.get(key); existing.count++; existing.totalDuration += track.duration_ms || 0; } else { artistCounts.set(key, { artist, count: 1, totalDuration: track.duration_ms || 0 }); } }); }); const topArtists = Array.from(artistCounts.values()) .sort((a, b) => b.count - a.count) .slice(0, 10) .map(item => ({ ...item.artist, playCount: item.count, totalDuration: item.totalDuration })); // Top genres (extract from track data if available) const genreCounts = new Map(); tracks.forEach(track => { // Try to extract genres from track data if (track.album?.genres) { track.album.genres.forEach((genre) => { genreCounts.set(genre, (genreCounts.get(genre) || 0) + 1); }); } }); const topGenres = Array.from(genreCounts.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([genre, count]) => ({ genre, count })); // Listening patterns const hours = new Array(24).fill(0); const days = new Array(7).fill(0); const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; tracks.forEach(track => { const date = new Date(track.played_at); hours[date.getHours()]++; days[date.getDay()]++; }); const mostActiveHour = hours.indexOf(Math.max(...hours)); const mostActiveDay = dayNames[days.indexOf(Math.max(...days))]; // Calculate listening streak (consecutive days with at least one play) const playDates = new Set(tracks.map(track => new Date(track.played_at).toDateString())); const sortedDates = Array.from(playDates).sort().reverse(); let streak = 0; let currentDate = new Date(); let lastPlayDate = null; for (let i = 0; i < sortedDates.length; i++) { const playDate = new Date(sortedDates[i]); const daysDiff = lastPlayDate ? Math.floor((lastPlayDate.getTime() - playDate.getTime()) / (1000 * 60 * 60 * 24)) : Math.floor((currentDate.getTime() - playDate.getTime()) / (1000 * 60 * 60 * 24)); if (daysDiff === 1 || (i === 0 && daysDiff <= 1)) { streak++; lastPlayDate = playDate; } else { break; } } // Get partner data if available let partnerData = null; try { const partner = db.db.prepare('SELECT user_b_id FROM friendships WHERE user_a_id = ? UNION SELECT user_a_id FROM friendships WHERE user_b_id = ?').get(uid, uid); if (partner?.user_b_id) { const partnerTracks = db.db.prepare(` SELECT played_at, track_json FROM recently_played WHERE user_id = ? ${timeFilter} ORDER BY played_at DESC `).all(...timeParams.slice(0, -1), partner.user_b_id, ...timeParams.slice(-1)); if (partnerTracks.length > 0) { const partnerTracksParsed = partnerTracks.map(row => { const track = JSON.parse(row.track_json); return { ...track, played_at: row.played_at, duration_ms: track.duration_ms || 0 }; }); partnerData = { totalTracks: partnerTracksParsed.length, totalListeningTime: partnerTracksParsed.reduce((sum, track) => sum + (track.duration_ms || 0), 0), topSongs: partnerTracksParsed.slice(0, 5), topArtists: partnerTracksParsed.slice(0, 5).map(track => track.artists?.[0]).filter(Boolean) }; } } } catch (e) { // Partner data not available } // Calculate additional unique categories const uniqueCategories = { // Most played song this week songOfTheWeek: tracks.filter(track => { const weekAgo = Date.now() - (7 * 24 * 60 * 60 * 1000); return track.played_at >= weekAgo; }).reduce((acc, track) => { acc[track.id] = (acc[track.id] || 0) + 1; return acc; }, {}), // Longest listening session (consecutive plays) longestSession: calculateLongestSession(tracks), // Most diverse day (most unique artists in one day) mostDiverseDay: calculateMostDiverseDay(tracks), // Late night listener (plays after 11 PM) lateNightListener: tracks.filter(track => new Date(track.played_at).getHours() >= 23).length, // Early bird (plays before 6 AM) earlyBird: tracks.filter(track => new Date(track.played_at).getHours() < 6).length, // Weekend warrior (plays on weekends) weekendWarrior: tracks.filter(track => { const day = new Date(track.played_at).getDay(); return day === 0 || day === 6; }).length, // Discovery rate (new artists discovered) discoveryRate: new Set(tracks.map(track => track.artists?.[0]?.id).filter(Boolean)).size, // Average song length averageSongLength: tracks.length > 0 ? tracks.reduce((sum, track) => sum + (track.duration_ms || 0), 0) / tracks.length : 0, // Most active month mostActiveMonth: getMostActiveMonth(tracks), // Listening velocity (songs per day) listeningVelocity: calculateListeningVelocity(tracks, timeRange) }; // Get data collection start date const firstPlay = db.db.prepare('SELECT MIN(played_at) as first_play FROM recently_played WHERE user_id = ?').get(uid); const dataCollectionStart = firstPlay?.first_play || Date.now(); res.json({ totalTracks: tracks.length, totalListeningTime, topSongs, topArtists, topGenres, listeningPatterns: { mostActiveHour, mostActiveDay, listeningStreak: streak }, uniqueCategories, partnerData, dataCollectionStart, timeRange, generatedAt: Date.now() }); } catch (e) { console.error('Wrapped generation error:', e); res.status(500).json({ error: 'Failed to generate wrapped data', detail: e?.message }); } }); // Get artist details with profile picture usersRouter.get('/:uid/artist/:artistId', requireAuth, async (req, res) => { try { const { uid, artistId } = req.params; if (!req.user) return res.status(403).json({ error: 'Forbidden' }); if (req.user.uid !== uid) { const paired = db.db.prepare('SELECT 1 FROM friendships WHERE (user_a_id=? AND user_b_id=?) OR (user_a_id=? AND user_b_id=?)').get(req.user.uid, uid, uid, req.user.uid); if (!paired) return res.status(403).json({ error: 'Forbidden' }); } const accessToken = await ensureValidAccessToken(uid); const response = await axios.get(`https://api.spotify.com/v1/artists/${artistId}`, { headers: { Authorization: `Bearer ${accessToken}` } }); res.json(response.data); } catch (e) { console.error('Artist fetch error:', e); res.status(500).json({ error: 'Failed to fetch artist data', detail: e?.message }); } });