fix(scrobble): Metadaten-Bearbeitung zerlegt die Statistik nicht mehr
Top-Titel gruppierte nach song_id. Wird eine Datei beim Bearbeiten umbenannt, vergibt Navidrome eine neue id — der Titel erschien danach zweimal mit geteilten Zaehlern. Kuenstler/Alben/Genres gruppierten nach lower(), sodass schon ein doppeltes Leerzeichen zwei Eintraege ergab. Schluessel ist jetzt durchgehend normalisiert (lower + trim + Leerzeichen gefaltet), Top-Titel gruppiert nach Titel+Kuenstler statt nach song_id. Die Anzeigefelder kommen aus dem JUENGSTEN Play statt aus MAX(), sonst zeigte die Statistik nach einer Korrektur weiter die alte Schreibweise. Am laufenden Dienst geprueft: 40 Plays vor und 10 nach einer Umbenennung erscheinen als ein Eintrag mit 50 Plays, neuer Schreibweise und neuer songId. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
39d47b78d4
commit
da32d2911a
1 file changed
+56
-17
+56
-17
@@ -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}`
|
||||
|
||||
Reference in new issue
Block a user