129 lines
6.1 KiB
JavaScript
129 lines
6.1 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';
|
|
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 (user1_id=? AND user2_id=?) OR (user1_id=? AND user2_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 (user1_id=? AND user2_id=?) OR (user1_id=? AND user2_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 });
|
|
}
|
|
});
|