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:
1 parent
5a6cff8105
commit
91d0d5fd0b
7 files changed
+218
-81
No files matched your search
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user