spotify/server/dist/routes/users.js
2025-10-21 15:43:52 +02:00

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 });
}
});