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
@@ -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 = `
|
||||
<div class="modal-card" style="max-width:720px;width:calc(100vw - 32px);max-height:88vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border2);border-radius:18px;box-shadow:0 40px 80px #0009;overflow:hidden">
|
||||
<div class="modal-head" style="display:flex;align-items:center;gap:10px;padding:16px 18px;border-bottom:1px solid var(--border)">
|
||||
<span style="font-size:18px">🎧</span>
|
||||
<div style="font-size:17px;font-weight:800;letter-spacing:-.3px;flex:1">Musik-Match</div>
|
||||
<button id="compare-back" title="Andere Person" style="display:none;padding:6px 10px;color:var(--text2);font-size:14px;font-weight:600;background:none;border:none;cursor:pointer">↺ Wechseln</button>
|
||||
<button class="hbtn" onclick="closeCompare()" title="Schließen"
|
||||
style="padding:6px 10px;color:var(--text2);font-size:18px;background:none;border:none;cursor:pointer">✕</button>
|
||||
</div>
|
||||
<div class="compare-body" id="compare-body" style="padding:16px 18px;overflow-y:auto;min-height:50vh"></div>
|
||||
</div>`;
|
||||
// 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 `<div style="text-align:center;color:var(--danger);padding:40px 0">${esc(msg)}</div>`;
|
||||
}
|
||||
function _info(msg) {
|
||||
return `<div style="text-align:center;color:var(--text2);padding:40px 0;line-height:1.6">${msg}</div>`;
|
||||
}
|
||||
|
||||
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.<br>Trage die <b>Scrobble-URL</b> 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.<br>Sobald eine andere Person NovaSonic nutzt, kannst du euch hier vergleichen.');
|
||||
return;
|
||||
}
|
||||
const rows = _users.map(u => `
|
||||
<div data-partner="${esc(u.user)}" style="display:flex;align-items:center;gap:12px;padding:11px 12px;border-radius:12px;cursor:pointer;border:1px solid var(--border);margin-bottom:8px"
|
||||
onmouseover="this.style.background='var(--surface2)'" onmouseout="this.style.background='none'">
|
||||
<div style="width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,var(--accent),var(--accent-dark));display:flex;align-items:center;justify-content:center;font-weight:800;color:#000;flex-shrink:0">${esc((u.user[0] || '?').toUpperCase())}</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(u.user)}</div>
|
||||
<div style="font-size:12px;color:var(--text2)">${u.plays.toLocaleString('de-DE')} Plays</div>
|
||||
</div>
|
||||
<div style="color:var(--text2);font-size:18px">›</div>
|
||||
</div>`).join('');
|
||||
body.innerHTML = `<div style="font-size:13px;color:var(--text2);margin:2px 2px 14px">Mit wem willst du dich vergleichen, <b>${esc(_me || '')}</b>?</div>${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 `<div style="display:flex;flex-direction:column;align-items:center;gap:10px;margin:6px 0 22px">
|
||||
<div style="width:132px;height:132px;border-radius:50%;background:conic-gradient(var(--accent) ${deg}deg, var(--surface2) ${deg}deg);display:flex;align-items:center;justify-content:center">
|
||||
<div style="width:104px;height:104px;border-radius:50%;background:var(--surface);display:flex;flex-direction:column;align-items:center;justify-content:center">
|
||||
<div style="font-size:32px;font-weight:800;letter-spacing:-1px;line-height:1">${score}<span style="font-size:16px">%</span></div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-top:2px">Match</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:15px;font-weight:700">${esc(verdict)}</div>
|
||||
<div style="font-size:13px;color:var(--text2)"><b>${esc(d.userA)}</b> ⟷ <b>${esc(d.userB)}</b></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _totals(d) {
|
||||
const col = (name, t) => `
|
||||
<div style="flex:1;background:var(--surface2);border:1px solid var(--border);border-radius:12px;padding:12px 14px;min-width:0">
|
||||
<div style="font-weight:800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:6px">${esc(name)}</div>
|
||||
<div style="font-size:12px;color:var(--text2)">${(t.plays || 0).toLocaleString('de-DE')} Plays · ${(t.artists || 0).toLocaleString('de-DE')} Künstler</div>
|
||||
</div>`;
|
||||
return `<div style="display:flex;gap:10px;margin-bottom:22px">${col(d.userA, d.totalsA)}${col(d.userB, d.totalsB)}</div>`;
|
||||
}
|
||||
|
||||
function _h(t) {
|
||||
return `<div style="font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.4px;color:var(--text2);margin:0 0 10px">${esc(t)}</div>`;
|
||||
}
|
||||
|
||||
// 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 `<div ${nav} style="display:flex;align-items:center;gap:10px;padding:7px 0">
|
||||
<div style="width:34px;text-align:right;font-size:12px;color:var(--text2);font-weight:700">${a.playsA}</div>
|
||||
<div style="flex:1;display:flex;justify-content:flex-end"><div style="height:6px;background:var(--accent);border-radius:3px;width:${Math.round((a.playsA / max) * 100)}%"></div></div>
|
||||
<div style="flex:1.6;text-align:center;font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0">${esc(a.name)}</div>
|
||||
<div style="flex:1;display:flex;justify-content:flex-start"><div style="height:6px;background:var(--accent-dark, var(--accent));opacity:.7;border-radius:3px;width:${Math.round((a.playsB / max) * 100)}%"></div></div>
|
||||
<div style="width:34px;text-align:left;font-size:12px;color:var(--text2);font-weight:700">${a.playsB}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `${_h('Gemeinsame Künstler')}<div style="margin-bottom:22px">${rows}</div>`;
|
||||
}
|
||||
|
||||
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 `<div ${nav} style="display:flex;align-items:center;gap:10px;padding:6px 0">
|
||||
<div style="width:40px;height:40px;border-radius:6px;background:var(--surface2);flex-shrink:0;overflow:hidden">
|
||||
${cover ? `<img src="${esc(cover)}" loading="lazy" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">` : ''}
|
||||
</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(t.title || '—')}</div>
|
||||
<div style="font-size:12px;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(t.artist || '')}</div>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text2);font-weight:600;white-space:nowrap">${t.playsA} · ${t.playsB}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `${_h('Gemeinsame Songs')}<div style="margin-bottom:22px">${rows}</div>`;
|
||||
}
|
||||
|
||||
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 `<div ${nav} style="display:flex;align-items:center;gap:6px;padding:4px 0;font-size:13px">
|
||||
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(a.name)}</span>
|
||||
<span style="color:var(--text2);font-size:12px;font-weight:600">${a.plays}</span>
|
||||
</div>`;
|
||||
}).join('') || `<div style="color:var(--text2);font-size:13px;padding:4px 0">—</div>`;
|
||||
return `<div style="flex:1;min-width:0">
|
||||
<div style="font-size:12px;font-weight:800;color:var(--text2);margin-bottom:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(title)}</div>${rows}</div>`;
|
||||
};
|
||||
return `${_h('Nur bei…')}<div style="display:flex;gap:18px">${list(`Nur ${d.userA}`, d.onlyA)}${list(`Nur ${d.userB}`, d.onlyB)}</div>`;
|
||||
}
|
||||
Reference in new issue
Block a user