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
@@ -57,6 +57,7 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
|
|||||||
| Timestamped play-log (own scrobble) | ✅ | ✅ |
|
| Timestamped play-log (own scrobble) | ✅ | ✅ |
|
||||||
| Listening statistics view | ✅ | ✅ |
|
| Listening statistics view | ✅ | ✅ |
|
||||||
| Stats cover art (artist/album) | ✅ | ✅ |
|
| Stats cover art (artist/album) | ✅ | ✅ |
|
||||||
|
| Musik-Match (Head-to-Head) | ✅ | 🔜 |
|
||||||
| Yearly Wrapped | 🔜 | 🔜 |
|
| Yearly Wrapped | 🔜 | 🔜 |
|
||||||
| Romaji / Japanese sort | ✅ | ❌ |
|
| Romaji / Japanese sort | ✅ | ❌ |
|
||||||
| Settings (Navidrome, ABS, etc.) | ✅ | ✅ |
|
| Settings (Navidrome, ABS, etc.) | ✅ | ✅ |
|
||||||
|
|||||||
@@ -713,6 +713,7 @@
|
|||||||
<button style="flex:1;padding:12px;border-radius:9px;font-family:var(--font);font-size:14px;font-weight:600;cursor:pointer;border:none;background:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#000" onclick="saveSettings()">Speichern</button>
|
<button style="flex:1;padding:12px;border-radius:9px;font-family:var(--font);font-size:14px;font-weight:600;cursor:pointer;border:none;background:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#000" onclick="saveSettings()">Speichern</button>
|
||||||
</div>
|
</div>
|
||||||
<button style="width:100%;padding:10px;border-radius:9px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;background:none;border:1px solid var(--border);color:var(--text2);transition:background .15s" onmouseover="this.style.background='var(--surface2)'" onmouseout="this.style.background='none'" onclick="closeSettings(); openStats()">📊 Meine Statistiken</button>
|
<button style="width:100%;padding:10px;border-radius:9px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;background:none;border:1px solid var(--border);color:var(--text2);transition:background .15s" onmouseover="this.style.background='var(--surface2)'" onmouseout="this.style.background='none'" onclick="closeSettings(); openStats()">📊 Meine Statistiken</button>
|
||||||
|
<button style="width:100%;padding:10px;border-radius:9px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;background:none;border:1px solid var(--border);color:var(--text2);transition:background .15s" onmouseover="this.style.background='var(--surface2)'" onmouseout="this.style.background='none'" onclick="closeSettings(); openCompare()">🎧 Musik-Match</button>
|
||||||
<button style="width:100%;padding:10px;border-radius:9px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;background:none;border:1px solid rgba(224,85,85,0.3);color:var(--danger);transition:background .15s" onmouseover="this.style.background='rgba(224,85,85,0.1)'" onmouseout="this.style.background='none'" onclick="confirmDisconnect()">Abmelden</button>
|
<button style="width:100%;padding:10px;border-radius:9px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;background:none;border:1px solid rgba(224,85,85,0.3);color:var(--danger);transition:background .15s" onmouseover="this.style.background='rgba(224,85,85,0.1)'" onmouseout="this.style.background='none'" onclick="confirmDisconnect()">Abmelden</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'ns-v76';
|
const CACHE = 'ns-v77';
|
||||||
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
|
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
|
||||||
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
|
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
|
||||||
// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes.
|
// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes.
|
||||||
|
|||||||
@@ -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).
|
// 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,
|
// 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).
|
// so cleanup after deletion must be authorized by a currently-valid account).
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { api, fetchRomajiIndex, loadRomajiOverrides, loadArtistOverrides } from
|
|||||||
import { injectPWA } from './pwa.js';
|
import { injectPWA } from './pwa.js';
|
||||||
import { initPlayLog } from './player/scrobbleLog.js';
|
import { initPlayLog } from './player/scrobbleLog.js';
|
||||||
import { openStats, closeStats } from './ui/stats.js';
|
import { openStats, closeStats } from './ui/stats.js';
|
||||||
|
import { openCompare, closeCompare } from './ui/compare.js';
|
||||||
import { closeArtistPin, clearArtistPin } from './ui/artistPin.js';
|
import { closeArtistPin, clearArtistPin } from './ui/artistPin.js';
|
||||||
import { loadSavedTheme } from './themes.js';
|
import { loadSavedTheme } from './themes.js';
|
||||||
import { initScrollRows } from './ui/components.js';
|
import { initScrollRows } from './ui/components.js';
|
||||||
@@ -336,6 +337,7 @@ Object.assign(window, {
|
|||||||
openSettings, closeSettings, saveSettings, confirmDisconnect, checkBackend,
|
openSettings, closeSettings, saveSettings, confirmDisconnect, checkBackend,
|
||||||
openSidebar, closeSidebar, switchNavMode, showCtxMenu,
|
openSidebar, closeSidebar, switchNavMode, showCtxMenu,
|
||||||
openStats, closeStats,
|
openStats, closeStats,
|
||||||
|
openCompare, closeCompare,
|
||||||
closeArtistPin, clearArtistPin,
|
closeArtistPin, clearArtistPin,
|
||||||
// Views
|
// Views
|
||||||
loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist,
|
loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist,
|
||||||
|
|||||||
@@ -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