diff --git a/scrobble/scrobble-server.js b/scrobble/scrobble-server.js index 7ff444c..c72fb1a 100644 --- a/scrobble/scrobble-server.js +++ b/scrobble/scrobble-server.js @@ -172,6 +172,14 @@ app.get('/scrobble/history', async (req, res) => { // ?tz= seconds east of UTC for local-day bucketing (default 0) // ?bucket= day | month | year — granularity of the time series (default day) // ?limit= top-list size (default 20, max 100) +// Gruppierungsschluessel fuer Texte: Klein-/Grossschreibung, Rand-Leerzeichen und mehrfache +// Leerzeichen duerfen keine zwei Eintraege aus einem machen. Genau das passiert sonst, sobald +// jemand Metadaten bearbeitet ("Dat Adam" -> "DAT ADAM" waeren zwei Kuenstler in der Statistik). +// SQLite kennt kein Regex, deshalb wird die Leerzeichen-Faltung zweimal angewandt (deckt bis zu +// vier aufeinanderfolgende Leerzeichen ab — mehr kommt in Tags praktisch nicht vor). +const normKey = (col) => + `lower(trim(replace(replace(replace(COALESCE(${col},''), char(9), ' '), ' ', ' '), ' ', ' ')))`; + // Buckets are computed on the fly (GROUP BY strftime) — no rollup tables. const _STATS_FMT = { day: '%Y-%m-%d', month: '%Y-%m', year: '%Y' }; @@ -202,31 +210,62 @@ app.get('/scrobble/stats', async (req, res) => { // navigator so you can't step before logging began. const firstPlayedAt = db.prepare('SELECT MIN(played_at) AS t FROM plays WHERE user = :user').get({ user }).t || null; + // Anzeigename aus dem JUENGSTEN Play, nicht per MAX(): sonst gewinnt lexikografisch die alte + // Schreibweise und die Statistik zeigt nach einer Korrektur weiter den alten Namen. const topArtists = db.prepare(` - SELECT MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds - FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> '' - GROUP BY lower(artist) ORDER BY plays DESC, seconds DESC LIMIT :limit + WITH neueste AS ( + SELECT ${normKey('artist')} AS k, artist, artist_id, MAX(played_at) + FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> '' GROUP BY k + ), zaehler AS ( + SELECT ${normKey('artist')} AS k, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds + FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> '' GROUP BY k + ) + SELECT n.artist AS name, n.artist_id AS artistId, z.plays AS plays, z.seconds AS seconds + FROM neueste n JOIN zaehler z ON z.k = n.k + ORDER BY z.plays DESC, z.seconds DESC LIMIT :limit `).all(winL); const topAlbums = db.prepare(` - SELECT MAX(album) AS name, MAX(album_id) AS albumId, MAX(artist) AS artist, - MAX(cover_art) AS coverArt, COUNT(*) AS plays - FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> '' - GROUP BY lower(album), lower(COALESCE(artist,'')) ORDER BY plays DESC LIMIT :limit + WITH neueste AS ( + SELECT ${normKey('album')} AS k1, ${normKey('artist')} AS k2, + album, album_id, artist, cover_art, MAX(played_at) + FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> '' GROUP BY k1, k2 + ), zaehler AS ( + SELECT ${normKey('album')} AS k1, ${normKey('artist')} AS k2, COUNT(*) AS plays + FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> '' GROUP BY k1, k2 + ) + SELECT n.album AS name, n.album_id AS albumId, n.artist AS artist, + n.cover_art AS coverArt, z.plays AS plays + FROM neueste n JOIN zaehler z ON z.k1 = n.k1 AND z.k2 = n.k2 + ORDER BY z.plays DESC LIMIT :limit `).all(winL); + // Nicht nach song_id gruppieren: wird eine Datei beim Metadaten-Bearbeiten umbenannt, vergibt + // Navidrome eine NEUE id — der Titel erschiene dann zweimal mit geteilten Zaehlern. Schluessel ist + // deshalb Titel+Kuenstler; Anzeigefelder (inkl. song_id fuer die Navigation) kommen aus dem + // JUENGSTEN Play, also mit den frisch bearbeiteten Tags. Das `MAX(played_at)` mit ansonsten + // blanken Spalten ist dokumentiertes SQLite-Verhalten: es liefert die Zeile des Maximums. const topTracks = db.prepare(` - SELECT MAX(title) AS title, MAX(artist) AS artist, MAX(album) AS album, - MAX(album_id) AS albumId, MAX(artist_id) AS artistId, MAX(cover_art) AS coverArt, - song_id AS songId, COUNT(*) AS plays - FROM plays WHERE ${WHERE} - GROUP BY song_id ORDER BY plays DESC LIMIT :limit + WITH neueste AS ( + SELECT ${normKey('title')} AS k1, ${normKey('artist')} AS k2, + title, artist, album, album_id, artist_id, cover_art, song_id, MAX(played_at) + FROM plays WHERE ${WHERE} + GROUP BY k1, k2 + ), zaehler AS ( + SELECT ${normKey('title')} AS k1, ${normKey('artist')} AS k2, COUNT(*) AS plays + FROM plays WHERE ${WHERE} + GROUP BY k1, k2 + ) + SELECT n.title AS title, n.artist AS artist, n.album AS album, n.album_id AS albumId, + n.artist_id AS artistId, n.cover_art AS coverArt, n.song_id AS songId, z.plays AS plays + FROM neueste n JOIN zaehler z ON z.k1 = n.k1 AND z.k2 = n.k2 + ORDER BY z.plays DESC LIMIT :limit `).all(winL); const topGenres = db.prepare(` SELECT MAX(genre) AS name, COUNT(*) AS plays FROM plays WHERE ${WHERE} AND genre IS NOT NULL AND genre <> '' - GROUP BY lower(genre) ORDER BY plays DESC LIMIT :limit + GROUP BY ${normKey('genre')} ORDER BY plays DESC LIMIT :limit `).all(winL); const series = db.prepare(` @@ -280,14 +319,14 @@ app.get('/scrobble/compare', async (req, res) => { 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 + SELECT ${normKey('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) + GROUP BY ${normKey('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, + SELECT MAX(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 + FROM plays WHERE ${WHERE} GROUP BY ${normKey('title')}, ${normKey('artist')} `).all({ user: u, from, to }); const totalsOf = u => db.prepare( `SELECT COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds FROM plays WHERE ${WHERE}`