diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt index d9075c9..2c8e78d 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt @@ -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, diff --git a/public/sw.js b/public/sw.js index e8dde10..63c8dbd 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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. diff --git a/src/views/detail.js b/src/views/detail.js index 483c089..7a276ec 100644 --- a/src/views/detail.js +++ b/src/views/detail.js @@ -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 || []) : [];