fix: duplicate rows in artist "Top Songs" when a track exists twice in the library

Navidrome can hold the same track twice (downloaded as a single AND as an album track), so
getTopSongs returned two identical rows ("PIXELATED KISSES" x2). Collapse the top-songs list
by title+album for display (keeps the highest play count) on web + Android. SW ns-v59.

Note: this is a display fix — the underlying duplicate files remain a library-cleanup matter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-07-31 17:48:02 +02:00
1 parent fbee695aad
commit b3cf01dd3b
3 files changed
+12 -3

No files matched your search

@@ -84,7 +84,9 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
val info = infoDef.await()
val topDef = async { runCatching { repo.getTopSongs(artist.name) }.getOrDefault(emptyList()) }
val top = topDef.await()
// Navidrome can hold the same track twice (single + album track) → duplicate top-song
// rows ("PIXELATED KISSES" x2). Collapse by title+album (keeps the highest play count).
val top = topDef.await().distinctBy { "${it.title}|${it.album}".lowercase() }
_state.value = ArtistUiState(
artist = artist, info = info, topSongs = top,
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'ns-v58';
const CACHE = 'ns-v59';
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes.
+8 -1
View File
@@ -219,7 +219,14 @@ export async function loadArtistDetail(artist) {
// ── Top Songs ──
const ndAlbums = _ndArtist?.album || [];
const topSongs = topRes.status === 'fulfilled' ? (topRes.value?.topSongs?.song || []) : [];
// Navidrome can hold the same track twice (e.g. downloaded as a single AND as an album track),
// so getTopSongs returns duplicate rows ("PIXELATED KISSES" x2). Collapse by title+album for display.
const _rawTop = topRes.status === 'fulfilled' ? (topRes.value?.topSongs?.song || []) : [];
const _seenTop = new Set();
const topSongs = _rawTop.filter(s => {
const k = `${s.title}|${s.album}`.toLowerCase().replace(/[^a-z0-9|]/g, '');
return _seenTop.has(k) ? false : (_seenTop.add(k), true);
});
const iTracks = iTracksRes.status === 'fulfilled' ? (iTracksRes.value || []).filter(t => t.wrapperType === 'track') : [];
const iAlbums = iAlbumsRes.status === 'fulfilled' ? (iAlbumsRes.value || []) : [];