feat(web+android): Musik-Match wird zum Duell mit Anzeigetafel

Statt eines Prozentrings ("wie aehnlich seid ihr") zeigt die Ansicht jetzt, wer
worin gewinnt: fuenf Kategorien mit je einem Sieger und ein Gesamtstand.
Hoerzeit, Vielfalt, Tiefe (Plays je Kuenstler), Nachteule (Anteil vor 5 Uhr) und
Entdeckerdrang (Anteil exklusiver Kuenstler) — bewusst Kennzahlen zur Hoerweise
statt zur Menge, sonst gewinnt immer, wer laenger dabei ist.

Am laufenden Dienst mit zwei gegensaetzlichen Nutzern geprueft: 60 Plays a 300 s
ergeben 5,0 h und Tiefe 12,0, jeder dritte Play nachts ergibt 33 %, 60 von 62
exklusiven Kuenstlern ergeben 97 % — Endstand 3:2. Das JSON-Feldschema wurde
gegen das Kotlin-Modell abgeglichen.

Dazu drei Fixes im selben Bereich:
- Die Web-Ansicht lebte nur von Inline-Stilen mit festen Flex-Verhaeltnissen und
  zerfiel am Desktop. Jetzt ein Raster mit eigenen Klassen, eine Spalte am Handy,
  zwei ab 780 px; das Fenster musste dafuer von 720 auf 940 px, sonst haette die
  zweite Spalte nie ausgeloest.
- Gemeinsame Songs wurden ueber song_id abgeglichen und fielen auseinander,
  sobald eine Datei umbenannt wurde. Jetzt normalisierter Titel+Kuenstler.
- Media-Session: die Metadaten wurden feldweise mutiert (gegen Kachel-Flackern
  unter Windows), ueber MPRIS kam davon nichts an — Plasma-Widgets zeigten
  dauerhaft den zuerst gespielten Titel. Bei echtem Titelwechsel jetzt ein neues
  MediaMetadata, sonst weiter in-place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-06 13:55:16 +02:00
1 parent 5a6cff8105
commit 91d0d5fd0b
7 files changed
+218 -81

No files matched your search

+51 -2
View File
@@ -302,6 +302,9 @@ app.get('/scrobble/users', async (req, res) => {
res.json({ me, users: rows });
});
// Gegenstueck zu normKey() in JS — fuer Abgleiche, die nicht in SQL stattfinden.
const _norm = (v) => (v || '').toLowerCase().replace(/[\t\s]+/g, ' ').trim();
// 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.
@@ -355,11 +358,15 @@ app.get('/scrobble/compare', async (req, res) => {
.sort((x, y) => y.plays - x.plays).slice(0, 8)
.map(r => ({ name: r.name, artistId: r.artistId, plays: r.plays }));
// Gemeinsame Titel ueber Titel+Kuenstler abgleichen, NICHT ueber song_id: haben beide denselben
// Song aus verschiedenen Dateien gehoert — oder wurde eine Datei zwischendurch umbenannt, wobei
// Navidrome eine neue id vergibt —, galt er sonst faelschlich als nicht gemeinsam.
const tKey = (r) => `${_norm(r.title)}\u0000${_norm(r.artist)}`;
const aTracks = tracksOf(userA);
const bTrackMap = new Map(tracksOf(userB).map(r => [r.songId, r]));
const bTrackMap = new Map(tracksOf(userB).map(r => [tKey(r), r]));
const sharedTracks = [];
for (const ta of aTracks) {
const tb = bTrackMap.get(ta.songId);
const tb = bTrackMap.get(tKey(ta));
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,
@@ -367,9 +374,51 @@ app.get('/scrobble/compare', async (req, res) => {
}
sharedTracks.sort((x, y) => (y.playsA + y.playsB) - (x.playsA + x.playsB));
// ── Anzeigetafel: Kategorien, die je einen Sieger haben ──────────────────────
// Bewusst Kennzahlen, die etwas ueber die HOERWEISE sagen statt ueber schiere Menge — sonst
// gewinnt immer, wer laenger dabei ist. Unentschieden ist erlaubt und zaehlt fuer niemanden.
const nachtAnteil = u => {
const r = db.prepare(`
SELECT COUNT(*) AS n,
SUM(CASE WHEN CAST(strftime('%H', played_at + :tz, 'unixepoch') AS INTEGER) < 5
THEN 1 ELSE 0 END) AS nacht
FROM plays WHERE ${WHERE}
`).get({ user: u, from, to, tz: Math.trunc(Number(req.query.tz) || 0) });
return r.n ? (r.nacht || 0) / r.n : 0;
};
const tA = totalsOf(userA), tB = totalsOf(userB);
const exklusivAnteil = (mine, other) =>
mine.length ? mine.filter(r => !other.has(r.k)).length / mine.length : 0;
const tiefe = (t, artists) => (artists ? t.plays / artists : 0);
const kat = (schluessel, label, a, b, format, hoeherGewinnt = true) => {
const gleich = Math.abs(a - b) < 1e-9;
return {
key: schluessel, label,
a, b,
textA: format(a), textB: format(b),
sieger: gleich ? null : ((a > b) === hoeherGewinnt ? 'A' : 'B'),
};
};
const std = v => `${(v / 3600).toFixed(1)} h`;
const proz = v => `${Math.round(v * 100)} %`;
const zahl = v => v.toLocaleString('de-DE');
const komma = v => v.toFixed(1);
const kategorien = [
kat('hoerzeit', 'Hörzeit', tA.seconds, tB.seconds, std),
kat('vielfalt', 'Vielfalt', aArtists.length, bArtists.length, zahl),
kat('tiefe', 'Tiefe', tiefe(tA, aArtists.length), tiefe(tB, bArtists.length), komma),
kat('nachteule', 'Nachteule', nachtAnteil(userA), nachtAnteil(userB), proz),
kat('entdecker', 'Entdeckerdrang', exklusivAnteil(aArtists, bMap), exklusivAnteil(bArtists, aMap), proz),
];
const punkteA = kategorien.filter(k => k.sieger === 'A').length;
const punkteB = kategorien.filter(k => k.sieger === 'B').length;
res.json({
userA, userB, range: { from, to },
matchScore,
battle: { kategorien, punkteA, punkteB },
totalsA: { ...totalsOf(userA), artists: aArtists.length },
totalsB: { ...totalsOf(userB), artists: bArtists.length },
sharedArtists: sharedArtists.slice(0, 30),