From 065d2f7319886825347a69174655d610a01bd77a Mon Sep 17 00:00:00 2001 From: Honey Date: Fri, 31 Jul 2026 16:11:45 +0200 Subject: [PATCH] feat: disambiguate same-name artists by Deezer fan count + show fans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deezer's artist search doesn't rank by popularity, so a name collision (5 artists named "Joji") returned a niche one first (62 fans) instead of the real Joji (559k) — the artist page then loaded the wrong discography and owned songs didn't show as downloaded. - api.js: _dzArtist carries fanCount (nb_fan); artist search sorts by fanCount desc. - search.js: artist cards show the fan count as subtitle; fan count passed to the artist page. - detail.js: pick the most-popular name match (not the first); for local Navidrome artists with a real collision (>=2 same-name candidates), tie-break by SONG OVERLAP — prefer the Deezer candidate whose top tracks actually overlap the library (each artist has different songs, so this nails the right person). Costs a few calls only on a collision. - Artist hero shows " Fans" (immediately for global-search artists, after match for local). - Android: DzArtist.nb_fan + /search/artist sorted by nb_fan so the right artist is first. - Also: web global-search "unavailable" message now says Deezer, not Apple. SW ns-v52. Verified live against Deezer: both fan-sort and overlap select the real Joji (id 5078097). Co-Authored-By: Claude Opus 4.8 --- .../com/novasonic/app/api/ItunesClient.kt | 4 ++- .../novasonic/app/api/model/DeezerModels.kt | 1 + index.html | 2 +- public/sw.js | 2 +- src/api.js | 7 +++- src/utils.js | 9 +++++ src/views/detail.js | 36 +++++++++++++++++-- src/views/search.js | 25 +++++++------ styles.css | 2 ++ 9 files changed, 69 insertions(+), 19 deletions(-) diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/ItunesClient.kt b/android/app/src/main/kotlin/com/novasonic/app/api/ItunesClient.kt index 46b8070..b500147 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/api/ItunesClient.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/api/ItunesClient.kt @@ -49,7 +49,9 @@ object ItunesClient { val tracksD = async { runCatching { http.get("$BASE/search") { parameter("q", query); parameter("limit", limit) }.body>().data }.getOrDefault(emptyList()) } val albumsD = async { runCatching { http.get("$BASE/search/album") { parameter("q", query); parameter("limit", 12) }.body>().data }.getOrDefault(emptyList()) } val artistsD = async { runCatching { http.get("$BASE/search/artist") { parameter("q", query); parameter("limit", 8) }.body>().data }.getOrDefault(emptyList()) } - val results = artistsD.await().map { it.toItunesItem() } + + // Deezer doesn't rank artists by popularity — sort by nb_fan so a name collision + // (5× "Joji") resolves to the artist the user means (real Joji has 559k fans, not 62). + val results = artistsD.await().sortedByDescending { it.nb_fan ?: 0 }.map { it.toItunesItem() } + albumsD.await().map { it.toItunesItem() } + tracksD.await().map { it.toItunesItem() } ItunesSearchResponse(results = results) diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/model/DeezerModels.kt b/android/app/src/main/kotlin/com/novasonic/app/api/model/DeezerModels.kt index a0ee4b4..0a4f1bf 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/api/model/DeezerModels.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/api/model/DeezerModels.kt @@ -18,6 +18,7 @@ data class DzArtist( val picture_medium: String? = null, val picture_big: String? = null, val picture_xl: String? = null, + val nb_fan: Long? = null, // popularity — disambiguates same-name artists (5× "Joji") ) @Serializable diff --git a/index.html b/index.html index ed34da8..571f721 100644 --- a/index.html +++ b/index.html @@ -151,7 +151,7 @@
-
+
diff --git a/public/sw.js b/public/sw.js index ab5a04c..1d3257b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'ns-v51'; +const CACHE = 'ns-v52'; 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/api.js b/src/api.js index 38eb16c..4259d97 100644 --- a/src/api.js +++ b/src/api.js @@ -305,6 +305,9 @@ function _dzArtist(a) { wrapperType: 'artist', artistId: a.id, artistName: a.name, artworkUrl100: a.picture_big || a.picture_medium || '', + // Deezer's artist search does NOT rank by popularity — several artists can share a name + // (5× "Joji"). fanCount lets us pick the one the user means (see itunesSearch + detail.js). + fanCount: a.nb_fan ?? undefined, }; } @@ -323,7 +326,9 @@ export async function itunesSearch(query, entity='musicTrack', limit=25) { const txt = await r.text(); const data = (txt ? JSON.parse(txt) : {}).data || []; if (entity === 'album') return data.map(_dzAlbum); - if (entity === 'musicArtist') return data.map(_dzArtist); + // Sort artists by popularity so a name collision resolves to the artist the user means + // (Deezer returns them in an unhelpful order — the real "Joji" isn't first). + if (entity === 'musicArtist') return data.map(_dzArtist).sort((x, y) => (y.fanCount || 0) - (x.fanCount || 0)); return data.map(_dzTrack); } catch { _itunesLastError = 'network'; return []; } } diff --git a/src/utils.js b/src/utils.js index 8d4e96e..ae1b32b 100644 --- a/src/utils.js +++ b/src/utils.js @@ -45,6 +45,15 @@ export function formatDur(s) { return `${m}:${sec.toString().padStart(2,'0')}`; } +// Compact count for fan numbers etc.: 559534 → "559,5 Tsd.", 1200000 → "1,2 Mio." +export function fmtCount(n) { + if (n == null || isNaN(n)) return ''; + const de = x => x.toFixed(1).replace(/\.0$/, '').replace('.', ','); + if (n >= 1e6) return de(n / 1e6) + ' Mio.'; + if (n >= 1e3) return de(n / 1e3) + ' Tsd.'; + return String(n); +} + export function toast(msg) { const t = document.createElement('div'); t.className = 'toast'; diff --git a/src/views/detail.js b/src/views/detail.js index 0830c01..99dfdbf 100644 --- a/src/views/detail.js +++ b/src/views/detail.js @@ -1,5 +1,5 @@ import { S } from '../state.js'; -import { esc, toast } from '../utils.js'; +import { esc, toast, fmtCount } from '../utils.js'; import { api, coverUrl, itunesSearch, itunesLookupAlbum, itunesLookupArtistSongs, itunesLookupArtistAlbums, deezerArtistImage, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames, ensureArtistSortMap } from '../api.js'; import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem } from '../ui/components.js'; import { playQueue, dlSongLocal, toggleStar, parseArtists } from '../player/player.js'; @@ -58,6 +58,9 @@ function _makeTopsongRow(s, num, subtitle, list, idx) { export async function loadArtistDetail(artist) { const nameEl = document.getElementById('artist-detail-name'); nameEl.textContent = artist.name; + const fansEl = document.getElementById('artist-detail-fans'); + if (fansEl) { fansEl.textContent = ''; fansEl.classList.remove('show'); } + const _showFans = n => { if (fansEl && n) { fansEl.textContent = `${fmtCount(n)} Fans`; fansEl.classList.add('show'); } }; document.getElementById('artist-back-btn').onclick = () => _navGoBack(); if (S.cfg.showRomaji) { fetchRomajiIndex() @@ -85,6 +88,8 @@ export async function loadArtistDetail(artist) { const hasND = !!artist.id; const knownItunes = artist.itunesArtistId || null; + const knownFans = artist.itunesFanCount ?? null; + if (knownFans) _showFans(knownFans); // from the global-search card — show immediately // Fire all requests immediately so they run in parallel. const imgP = hasND ? getArtistImage(artist.id) : Promise.resolve(null); @@ -139,12 +144,39 @@ export async function loadArtistDetail(artist) { const _normWords = s => new Set((s || '').toLowerCase().replace(/[^a-z0-9]/g, ' ').split(/\s+/).filter(Boolean)); const _queryWords = _normWords(artist.name); const iArtistResults = iArtistRes.status === 'fulfilled' ? (iArtistRes.value || []) : []; - const iArtistMatch = iArtistResults.find(r => { + // Name-matching candidates, already ordered by fanCount (itunesSearch sorts musicArtist by nb_fan). + const nameMatches = iArtistResults.filter(r => { if (_queryWords.size === 0) return true; const rw = _normWords(r.artistName); return [..._queryWords].every(w => rw.has(w)); }); + // Default pick = most popular name match (fixes "5× Joji": Deezer's order buries the real one). + let iArtistMatch = nameMatches[0] || null; + + // Song-overlap tie-break: when several artists share the name AND this is a local (Navidrome) + // artist we own songs for, prefer the Deezer candidate whose top tracks actually overlap our + // library — each artist has different songs, so this nails the right person. Only runs on a real + // collision (≥2 name matches) and costs a few calls just for those. + if (!knownItunes && hasND && nameMatches.length > 1) { + const _nt = s => (s || '').toLowerCase().replace(/\([^)]*\)/g, '').replace(/[^a-z0-9]/g, ''); + const refTitles = new Set( + ((topRes.status === 'fulfilled' ? topRes.value?.topSongs?.song : []) || []) + .map(s => _nt(s.title)).filter(Boolean) + ); + if (refTitles.size) { + const cands = nameMatches.slice(0, 3); + const scored = await Promise.all(cands.map(async c => { + const tt = await itunesLookupArtistSongs(c.artistId, 15).catch(() => []); + const overlap = tt.reduce((n, t) => n + (refTitles.has(_nt(t.trackName)) ? 1 : 0), 0); + return { c, overlap }; + })); + scored.sort((a, b) => b.overlap - a.overlap || (b.c.fanCount || 0) - (a.c.fanCount || 0)); + if (scored[0]?.overlap > 0) iArtistMatch = scored[0].c; + } + } + const itunesArtistId = knownItunes ?? iArtistMatch?.artistId ?? null; + if (!knownFans) _showFans(iArtistMatch?.fanCount); // local artist: show the matched Deezer artist's fans // Non-local (Deezer) artist has no Navidrome photo — fetch the Deezer artist picture for the hero. if (!hasND && itunesArtistId && pfpWrap) { diff --git a/src/views/search.js b/src/views/search.js index 41825b9..f93d044 100644 --- a/src/views/search.js +++ b/src/views/search.js @@ -1,5 +1,5 @@ import { S } from '../state.js'; -import { esc, toast } from '../utils.js'; +import { esc, toast, fmtCount } from '../utils.js'; import { api, itunesSearch, itunesLastError, itunesLookupAlbum, fetchRomajiIndex, dispArtistById } from '../api.js'; import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem, mkSection } from '../ui/components.js'; import { playPreview } from '../player/player.js'; @@ -130,8 +130,9 @@ export async function doGlobalSearch(q) { const s = mkSection('Künstler'); const row = document.createElement('div'); row.className = 'scroll-row'; artists.forEach(a => { - const c = makeCard(a.artistName, 'Deezer Künstler', a.artworkUrl100 || null, '🎤'); - c.onclick = () => loadItunesArtistResults(a.artistId, a.artistName); + const sub = a.fanCount ? `${fmtCount(a.fanCount)} Fans` : 'Deezer Künstler'; + const c = makeCard(a.artistName, sub, a.artworkUrl100 || null, '🎤'); + c.onclick = () => loadItunesArtistResults(a.artistId, a.artistName, a.fanCount); row.appendChild(c); if (a.artworkUrl100) return; // Deezer artists already carry a photo — no extra lookup itunesSearch(a.artistName,'album',1).then(res => { @@ -178,14 +179,12 @@ export async function doGlobalSearch(q) { if(!artists.length && !albums.length && !trackResults.length) { const err = itunesLastError(); if (err) { - // Distinguish "genuinely no results" from "Apple search is down" so the user knows where the - // problem is. Apple currently blocks the Search API (/search) with 403 from many server IPs. - const detail = err === 403 - ? 'Apple blockt gerade die Such-API (Fehler 403). Das liegt an Apple, nicht an dir — Downloads per Direktlink funktionieren weiter.' - : err === 'network' - ? 'Keine Verbindung zur Apple-Such-API (Netzwerk/Backend). Läuft dein Backend?' - : `Apple-Suche nicht verfügbar (Fehler ${err}).`; - el.innerHTML = `
⚠️

Apple-Suche gerade nicht verfügbar

${detail}

`; + // Distinguish "genuinely no results" from "search backend is down" so the user knows where + // the problem is. + const detail = err === 'network' + ? 'Keine Verbindung zur Deezer-Such-API (Netzwerk/Backend). Läuft dein Backend?' + : `Deezer-Suche nicht verfügbar (Fehler ${err}).`; + el.innerHTML = `
⚠️

Deezer-Suche gerade nicht verfügbar

${detail}

`; } else { el.innerHTML = '
🔍

Keine Ergebnisse gefunden

'; } @@ -215,8 +214,8 @@ export async function loadItunesAlbum(album) { } catch(e) { toast('Fehler: ' + e.message); } } -export function loadItunesArtistResults(artistId, artistName) { - navigate('artist-detail', { id: null, name: artistName, itunesArtistId: artistId }); +export function loadItunesArtistResults(artistId, artistName, fanCount) { + navigate('artist-detail', { id: null, name: artistName, itunesArtistId: artistId, itunesFanCount: fanCount }); } // ─── SEARCH HELPERS ─── diff --git a/styles.css b/styles.css index c6259cf..3722c9e 100644 --- a/styles.css +++ b/styles.css @@ -848,6 +848,8 @@ button:focus{outline:none} .artist-hero-pfp-initial{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:52px;font-weight:700;color:var(--text2);line-height:1} .artist-hero-info{min-width:0;flex:1;display:flex;align-items:flex-end;padding:20px 24px;background:linear-gradient(to left,rgba(7,7,15,.95) 0%,rgba(7,7,15,.1) 100%)} .artist-hero-name{font-size:26px;font-weight:800;letter-spacing:-0.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.artist-hero-fans{font-size:12px;font-weight:600;color:var(--text2);margin-top:3px;display:none} +.artist-hero-fans.show{display:block} @media(max-width:768px){ .artist-hero-bg{filter:brightness(0.75) saturate(0.85);transform:scale(1)} .artist-hero-pfp-wrap{display:none}