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 `