From cbedbba6fccec3696404548786f4c06057445a9b Mon Sep 17 00:00:00 2001 From: Honey Date: Thu, 13 Aug 2026 00:46:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(scrobble):=20Head-to-Head=20"Musik-Match"?= =?UTF-8?q?=20=E2=80=94=20compare=20two=20users'=20listening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 1 + index.html | 1 + public/sw.js | 2 +- scrobble/scrobble-server.js | 89 ++++++++++++++ src/main.js | 2 + src/ui/compare.js | 223 ++++++++++++++++++++++++++++++++++++ 6 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 src/ui/compare.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 00dd835..2052f43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Versioning: two-part `MAJOR.MINOR` β€” each release bumps the minor (features an | Timestamped play-log (own scrobble) | βœ… | βœ… | | Listening statistics view | βœ… | βœ… | | Stats cover art (artist/album) | βœ… | βœ… | +| Musik-Match (Head-to-Head) | βœ… | πŸ”œ | | Yearly Wrapped | πŸ”œ | πŸ”œ | | Romaji / Japanese sort | βœ… | ❌ | | Settings (Navidrome, ABS, etc.) | βœ… | βœ… | diff --git a/index.html b/index.html index e1525f9..36c6d3e 100644 --- a/index.html +++ b/index.html @@ -713,6 +713,7 @@ + diff --git a/public/sw.js b/public/sw.js index e656c12..53a53b2 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'ns-v76'; +const CACHE = 'ns-v77'; const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg']; const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js']; // Auth token params stripped from cache keys so artwork survives across sessions/token refreshes. diff --git a/scrobble/scrobble-server.js b/scrobble/scrobble-server.js index ea033f1..7ff444c 100644 --- a/scrobble/scrobble-server.js +++ b/scrobble/scrobble-server.js @@ -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). diff --git a/src/main.js b/src/main.js index eb21feb..836e9cc 100644 --- a/src/main.js +++ b/src/main.js @@ -4,6 +4,7 @@ import { api, fetchRomajiIndex, loadRomajiOverrides, loadArtistOverrides } from import { injectPWA } from './pwa.js'; import { initPlayLog } from './player/scrobbleLog.js'; import { openStats, closeStats } from './ui/stats.js'; +import { openCompare, closeCompare } from './ui/compare.js'; import { closeArtistPin, clearArtistPin } from './ui/artistPin.js'; import { loadSavedTheme } from './themes.js'; import { initScrollRows } from './ui/components.js'; @@ -336,6 +337,7 @@ Object.assign(window, { openSettings, closeSettings, saveSettings, confirmDisconnect, checkBackend, openSidebar, closeSidebar, switchNavMode, showCtxMenu, openStats, closeStats, + openCompare, closeCompare, closeArtistPin, clearArtistPin, // Views loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist, diff --git a/src/ui/compare.js b/src/ui/compare.js new file mode 100644 index 0000000..4b585a3 --- /dev/null +++ b/src/ui/compare.js @@ -0,0 +1,223 @@ +// ─── Head-to-Head "Musik-Match" ───────────────────────────────────────────── +// Opened from Settings ("Musik-Match"). Compares the logged-in user's play-log +// against another user's (GET /scrobble/compare) and renders a taste-match score +// (cosine over artist-play vectors), shared artists/tracks, and each side's unique +// top artists. The partner is chosen from a picker fed by GET /scrobble/users. +// Self-contained overlay, mirroring stats.js. + +import { S } from '../state.js'; +import { esc } from '../utils.js'; +import { subsonicAuthParams, coverUrl } from '../api.js'; + +let _overlay = null; +let _me = null; // authed username (userA) +let _partner = null; // chosen userB +let _users = []; // [{ user, plays, lastPlayedAt }] + +export function openCompare() { + if (!_overlay) _buildOverlay(); + _overlay.classList.add('open'); + document.body.style.overflow = 'hidden'; + _loadUsers(); +} + +export function closeCompare() { + if (_overlay) _overlay.classList.remove('open'); + document.body.style.overflow = ''; +} + +// Called from the rendered partner list (delegated below) β€” kept module-local. +function _choosePartner(name) { _partner = name; _loadCompare(); } + +function _buildOverlay() { + _overlay = document.createElement('div'); + _overlay.className = 'modal-overlay'; + _overlay.id = 'compare-overlay'; + _overlay.addEventListener('click', e => { if (e.target === _overlay) closeCompare(); }); + _overlay.innerHTML = ` + `; + // Delegated: pick a partner, or navigate to an artist/album row. + _overlay.querySelector('#compare-body').addEventListener('click', async e => { + const pick = e.target.closest('[data-partner]'); + if (pick) { _choosePartner(pick.dataset.partner); return; } + const row = e.target.closest('[data-nav-type]'); + if (!row) return; + const { navType, navId, navName, navArtist } = row.dataset; + closeCompare(); + const { navigate } = await import('../views/views.js'); + if (navType === 'artist') navigate('artist-detail', { id: navId, name: navName }); + else if (navType === 'album') navigate('album-detail', { id: navId, name: navName, artist: navArtist || '' }); + }); + _overlay.querySelector('#compare-back').addEventListener('click', () => { _partner = null; _renderPicker(); }); + document.body.appendChild(_overlay); +} + +function _errBox(msg) { + return `
${esc(msg)}
`; +} +function _info(msg) { + return `
${msg}
`; +} + +async function _loadUsers() { + const body = _overlay.querySelector('#compare-body'); + _overlay.querySelector('#compare-back').style.display = 'none'; + _partner = null; + if (!S.cfg.scrobbleUrl) { + body.innerHTML = _info('Kein Scrobble-Dienst konfiguriert.
Trage die Scrobble-URL in den Einstellungen ein.'); + return; + } + const auth = subsonicAuthParams(); + if (!auth) { body.innerHTML = _errBox('Nicht angemeldet.'); return; } + body.innerHTML = _info('Lade…'); + try { + const res = await fetch(`${S.cfg.scrobbleUrl}/scrobble/users?${auth}`); + if (!res.ok) { body.innerHTML = _errBox(`Fehler ${res.status}`); return; } + const d = await res.json(); + _me = d.me; + _users = (d.users || []).filter(u => u.user !== _me); + _renderPicker(); + } catch { + body.innerHTML = _errBox('Scrobble-Dienst nicht erreichbar.'); + } +} + +function _renderPicker() { + const body = _overlay.querySelector('#compare-body'); + _overlay.querySelector('#compare-back').style.display = 'none'; + if (!_users.length) { + body.innerHTML = _info('Noch niemand sonst hat Wiedergaben aufgezeichnet.
Sobald eine andere Person NovaSonic nutzt, kannst du euch hier vergleichen.'); + return; + } + const rows = _users.map(u => ` +
+
${esc((u.user[0] || '?').toUpperCase())}
+
+
${esc(u.user)}
+
${u.plays.toLocaleString('de-DE')} Plays
+
+
β€Ί
+
`).join(''); + body.innerHTML = `
Mit wem willst du dich vergleichen, ${esc(_me || '')}?
${rows}`; +} + +async function _loadCompare() { + const body = _overlay.querySelector('#compare-body'); + _overlay.querySelector('#compare-back').style.display = 'inline-block'; + const auth = subsonicAuthParams(); + body.innerHTML = _info('Vergleiche…'); + try { + const res = await fetch(`${S.cfg.scrobbleUrl}/scrobble/compare?${auth}&with=${encodeURIComponent(_partner)}`); + if (!res.ok) { body.innerHTML = _errBox(`Fehler ${res.status}`); return; } + _renderCompare(body, await res.json()); + } catch { + body.innerHTML = _errBox('Scrobble-Dienst nicht erreichbar.'); + } +} + +function _renderCompare(body, d) { + const score = Math.max(0, Math.min(100, d.matchScore || 0)); + const verdict = + score >= 80 ? 'Seelenverwandte 🎯' : + score >= 60 ? 'Starkes Match πŸ”₯' : + score >= 40 ? 'Solide Überschneidung πŸ‘' : + score >= 20 ? 'Ein paar Gemeinsamkeiten πŸ™‚' : + 'Ziemlich unterschiedlich πŸŒ—'; + + body.innerHTML = [ + _scoreRing(d, score, verdict), + _totals(d), + _sharedArtists(d.sharedArtists), + _sharedTracks(d.sharedTracks), + _uniqueCols(d), + ].join(''); +} + +function _scoreRing(d, score, verdict) { + const deg = Math.round(score * 3.6); + return `
+
+
+
${score}%
+
Match
+
+
+
${esc(verdict)}
+
${esc(d.userA)}  βŸ·  ${esc(d.userB)}
+
`; +} + +function _totals(d) { + const col = (name, t) => ` +
+
${esc(name)}
+
${(t.plays || 0).toLocaleString('de-DE')} Plays Β· ${(t.artists || 0).toLocaleString('de-DE')} KΓΌnstler
+
`; + return `
${col(d.userA, d.totalsA)}${col(d.userB, d.totalsB)}
`; +} + +function _h(t) { + return `
${esc(t)}
`; +} + +// Shared artist row: a centered name with each side's play count on its flank + a +// mirrored bar so you can see who plays them more. +function _sharedArtists(items) { + if (!items?.length) return `${_h('Gemeinsame KΓΌnstler')}${_info('Keine gemeinsamen KΓΌnstler in diesem Zeitraum.')}`; + const max = Math.max(...items.map(a => Math.max(a.playsA, a.playsB)), 1); + const rows = items.slice(0, 15).map(a => { + const nav = a.artistId ? `data-nav-type="artist" data-nav-id="${esc(a.artistId)}" data-nav-name="${esc(a.name)}" style="cursor:pointer"` : ''; + return `
+
${a.playsA}
+
+
${esc(a.name)}
+
+
${a.playsB}
+
`; + }).join(''); + return `${_h('Gemeinsame KΓΌnstler')}
${rows}
`; +} + +function _sharedTracks(items) { + if (!items?.length) return ''; + const rows = items.slice(0, 12).map(t => { + const cover = coverUrl(t.coverArt || t.albumId || t.songId, 80); + const nav = t.albumId ? `data-nav-type="album" data-nav-id="${esc(t.albumId)}" data-nav-name="${esc(t.album || '')}" data-nav-artist="${esc(t.artist || '')}" style="cursor:pointer"` : ''; + return `
+
+ ${cover ? `` : ''} +
+
+
${esc(t.title || 'β€”')}
+
${esc(t.artist || '')}
+
+
${t.playsA} Β· ${t.playsB}
+
`; + }).join(''); + return `${_h('Gemeinsame Songs')}
${rows}
`; +} + +function _uniqueCols(d) { + const list = (title, arr) => { + const rows = (arr || []).slice(0, 6).map(a => { + const nav = a.artistId ? `data-nav-type="artist" data-nav-id="${esc(a.artistId)}" data-nav-name="${esc(a.name)}" style="cursor:pointer"` : ''; + return `
+ ${esc(a.name)} + ${a.plays} +
`; + }).join('') || `
β€”
`; + return `
+
${esc(title)}
${rows}
`; + }; + return `${_h('Nur bei…')}
${list(`Nur ${d.userA}`, d.onlyA)}${list(`Nur ${d.userB}`, d.onlyB)}
`; +}