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:
HoneyandClaude Opus 4.8 committed 2026-08-13 00:46:29 +02:00
1 parent ba2306656e
commit cbedbba6fc
6 files changed
+317 -1

No files matched your search

+89
View File
@@ -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).