473 lines
21 KiB
JavaScript
473 lines
21 KiB
JavaScript
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 });
|
|
}
|
|
});
|