feat(scrobble): Head-to-Head "Musik-Match" — compare two users' listening
Adds GET /scrobble/users (distinct users for the partner picker) and GET /scrobble/compare?with= to the scrobble service. Taste match = cosine of the two users' artist-play vectors (0..100); also returns shared artists (mirrored bars), shared tracks, and each side's unique top artists. Reuses the existing per-user play-log — no new data capture. Web UI src/ui/compare.js (overlay from Settings "🎧 Musik-Match"): partner picker fed by /scrobble/users, match-score ring, per-user totals, shared + unique lists with clickable artist/album rows. Cosine + shared logic verified against a hand computation on an in-memory DB. Android pending (parity: 🔜). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
ba2306656e
commit
cbedbba6fc
6 files changed
+317
-1
No files matched your search
@@ -251,6 +251,95 @@ app.get('/scrobble/stats', async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// GET /scrobble/users — distinct users in the log, for the Head-to-Head partner picker.
|
||||
// Any valid Navidrome login may list them (so you can pick who to compare against).
|
||||
app.get('/scrobble/users', async (req, res) => {
|
||||
const me = await verifyUser(req);
|
||||
if (!me) return res.status(401).json({ error: 'unauthorized' });
|
||||
const rows = db.prepare(`
|
||||
SELECT user, COUNT(*) AS plays, MAX(played_at) AS lastPlayedAt
|
||||
FROM plays GROUP BY user ORDER BY plays DESC
|
||||
`).all();
|
||||
res.json({ me, users: rows });
|
||||
});
|
||||
|
||||
// GET /scrobble/compare?with=NAME&from= — Head-to-Head "Musik-Match" of the authed user (A) vs
|
||||
// another user (B). Taste similarity = cosine of the two artist-play vectors (robust, 0..100).
|
||||
// Also returns shared artists/tracks and each side's unique top artists.
|
||||
app.get('/scrobble/compare', async (req, res) => {
|
||||
const userA = await verifyUser(req);
|
||||
if (!userA) return res.status(401).json({ error: 'unauthorized' });
|
||||
const userB = String(req.query.with || '').trim();
|
||||
if (!userB) return res.status(400).json({ error: 'missing ?with=' });
|
||||
if (userB === userA) return res.status(400).json({ error: 'same user' });
|
||||
|
||||
// Window (default all-time). Parse `from` directly — _toEpochSec maps 0 → now (see /stats).
|
||||
const fromN = Number(req.query.from);
|
||||
const from = Number.isFinite(fromN) && fromN > 0 ? (fromN > 1e12 ? Math.floor(fromN / 1000) : Math.floor(fromN)) : 0;
|
||||
const to = req.query.to ? _toEpochSec(req.query.to) : Math.floor(Date.now() / 1000) + 1;
|
||||
const WHERE = 'user = :user AND played_at >= :from AND played_at < :to';
|
||||
|
||||
const artistsOf = u => db.prepare(`
|
||||
SELECT lower(artist) AS k, MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays
|
||||
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> ''
|
||||
GROUP BY lower(artist)
|
||||
`).all({ user: u, from, to });
|
||||
const tracksOf = u => db.prepare(`
|
||||
SELECT song_id AS songId, MAX(title) AS title, MAX(artist) AS artist, MAX(artist_id) AS artistId,
|
||||
MAX(album_id) AS albumId, MAX(cover_art) AS coverArt, COUNT(*) AS plays
|
||||
FROM plays WHERE ${WHERE} GROUP BY song_id
|
||||
`).all({ user: u, from, to });
|
||||
const totalsOf = u => db.prepare(
|
||||
`SELECT COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds FROM plays WHERE ${WHERE}`
|
||||
).get({ user: u, from, to });
|
||||
|
||||
const aArtists = artistsOf(userA), bArtists = artistsOf(userB);
|
||||
const aMap = new Map(aArtists.map(r => [r.k, r]));
|
||||
const bMap = new Map(bArtists.map(r => [r.k, r]));
|
||||
|
||||
// Cosine similarity over the artist-play vectors → a stable 0..100 "match".
|
||||
let dot = 0, nA = 0, nB = 0;
|
||||
for (const r of aArtists) nA += r.plays * r.plays;
|
||||
for (const r of bArtists) nB += r.plays * r.plays;
|
||||
for (const [k, ra] of aMap) { const rb = bMap.get(k); if (rb) dot += ra.plays * rb.plays; }
|
||||
const matchScore = (nA && nB) ? Math.round((dot / (Math.sqrt(nA) * Math.sqrt(nB))) * 100) : 0;
|
||||
|
||||
const sharedArtists = [];
|
||||
for (const [k, ra] of aMap) {
|
||||
const rb = bMap.get(k);
|
||||
if (rb) sharedArtists.push({ name: ra.name, artistId: ra.artistId || rb.artistId, playsA: ra.plays, playsB: rb.plays });
|
||||
}
|
||||
sharedArtists.sort((x, y) => (y.playsA + y.playsB) - (x.playsA + x.playsB));
|
||||
|
||||
const onlyTop = (mine, other) => mine
|
||||
.filter(r => !other.has(r.k))
|
||||
.sort((x, y) => y.plays - x.plays).slice(0, 8)
|
||||
.map(r => ({ name: r.name, artistId: r.artistId, plays: r.plays }));
|
||||
|
||||
const aTracks = tracksOf(userA);
|
||||
const bTrackMap = new Map(tracksOf(userB).map(r => [r.songId, r]));
|
||||
const sharedTracks = [];
|
||||
for (const ta of aTracks) {
|
||||
const tb = bTrackMap.get(ta.songId);
|
||||
if (tb) sharedTracks.push({
|
||||
songId: ta.songId, title: ta.title, artist: ta.artist, artistId: ta.artistId,
|
||||
albumId: ta.albumId, coverArt: ta.coverArt, playsA: ta.plays, playsB: tb.plays,
|
||||
});
|
||||
}
|
||||
sharedTracks.sort((x, y) => (y.playsA + y.playsB) - (x.playsA + x.playsB));
|
||||
|
||||
res.json({
|
||||
userA, userB, range: { from, to },
|
||||
matchScore,
|
||||
totalsA: { ...totalsOf(userA), artists: aArtists.length },
|
||||
totalsB: { ...totalsOf(userB), artists: bArtists.length },
|
||||
sharedArtists: sharedArtists.slice(0, 30),
|
||||
sharedTracks: sharedTracks.slice(0, 30),
|
||||
onlyA: onlyTop(aArtists, bMap),
|
||||
onlyB: onlyTop(bArtists, aMap),
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /scrobble/plays?user=NAME — purge one user's rows (for test cleanup).
|
||||
// Requires ANY valid Navidrome login (a deleted user can't authenticate as itself,
|
||||
// so cleanup after deletion must be authorized by a currently-valid account).
|
||||
|
||||
Reference in new issue
Block a user