feat: disambiguate same-name artists by Deezer fan count + show fans

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 "<n> 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 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-07-31 16:11:45 +02:00
1 parent 1e69831dd8
commit 065d2f7319
9 files changed
+69 -19

No files matched your search

@@ -49,7 +49,9 @@ object ItunesClient {
val tracksD = async { runCatching { http.get("$BASE/search") { parameter("q", query); parameter("limit", limit) }.body<DzList<DzTrack>>().data }.getOrDefault(emptyList()) } val tracksD = async { runCatching { http.get("$BASE/search") { parameter("q", query); parameter("limit", limit) }.body<DzList<DzTrack>>().data }.getOrDefault(emptyList()) }
val albumsD = async { runCatching { http.get("$BASE/search/album") { parameter("q", query); parameter("limit", 12) }.body<DzList<DzAlbum>>().data }.getOrDefault(emptyList()) } val albumsD = async { runCatching { http.get("$BASE/search/album") { parameter("q", query); parameter("limit", 12) }.body<DzList<DzAlbum>>().data }.getOrDefault(emptyList()) }
val artistsD = async { runCatching { http.get("$BASE/search/artist") { parameter("q", query); parameter("limit", 8) }.body<DzList<DzArtist>>().data }.getOrDefault(emptyList()) } val artistsD = async { runCatching { http.get("$BASE/search/artist") { parameter("q", query); parameter("limit", 8) }.body<DzList<DzArtist>>().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() } + albumsD.await().map { it.toItunesItem() } +
tracksD.await().map { it.toItunesItem() } tracksD.await().map { it.toItunesItem() }
ItunesSearchResponse(results = results) ItunesSearchResponse(results = results)
@@ -18,6 +18,7 @@ data class DzArtist(
val picture_medium: String? = null, val picture_medium: String? = null,
val picture_big: String? = null, val picture_big: String? = null,
val picture_xl: String? = null, val picture_xl: String? = null,
val nb_fan: Long? = null, // popularity — disambiguates same-name artists (5× "Joji")
) )
@Serializable @Serializable
+1 -1
View File
@@ -151,7 +151,7 @@
<!-- ARTIST DETAIL --> <!-- ARTIST DETAIL -->
<div class="page" id="page-artist-detail"> <div class="page" id="page-artist-detail">
<button class="back-btn" id="artist-back-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Alle Künstler</button> <button class="back-btn" id="artist-back-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Alle Künstler</button>
<div class="artist-hero" id="artist-detail-hero"><div class="artist-hero-bg" id="artist-hero-bg"></div><div class="artist-hero-overlay"><div class="artist-hero-pfp-wrap" id="artist-hero-pfp-wrap"><span class="artist-hero-pfp-initial" id="artist-hero-pfp-initial"></span></div><div class="artist-hero-info"><div class="artist-hero-name" id="artist-detail-name"></div></div></div></div> <div class="artist-hero" id="artist-detail-hero"><div class="artist-hero-bg" id="artist-hero-bg"></div><div class="artist-hero-overlay"><div class="artist-hero-pfp-wrap" id="artist-hero-pfp-wrap"><span class="artist-hero-pfp-initial" id="artist-hero-pfp-initial"></span></div><div class="artist-hero-info"><div class="artist-hero-name" id="artist-detail-name"></div><div class="artist-hero-fans" id="artist-detail-fans"></div></div></div></div>
<div id="artist-action-bar" class="artist-action-bar"></div> <div id="artist-action-bar" class="artist-action-bar"></div>
<div id="artist-bio-section"></div> <div id="artist-bio-section"></div>
<div id="artist-similar-section"></div> <div id="artist-similar-section"></div>
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'ns-v51'; const CACHE = 'ns-v52';
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg']; const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js']; const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes. // Auth token params stripped from cache keys so artwork survives across sessions/token refreshes.
+6 -1
View File
@@ -305,6 +305,9 @@ function _dzArtist(a) {
wrapperType: 'artist', wrapperType: 'artist',
artistId: a.id, artistName: a.name, artistId: a.id, artistName: a.name,
artworkUrl100: a.picture_big || a.picture_medium || '', 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 txt = await r.text();
const data = (txt ? JSON.parse(txt) : {}).data || []; const data = (txt ? JSON.parse(txt) : {}).data || [];
if (entity === 'album') return data.map(_dzAlbum); 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); return data.map(_dzTrack);
} catch { _itunesLastError = 'network'; return []; } } catch { _itunesLastError = 'network'; return []; }
} }
+9
View File
@@ -45,6 +45,15 @@ export function formatDur(s) {
return `${m}:${sec.toString().padStart(2,'0')}`; 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) { export function toast(msg) {
const t = document.createElement('div'); const t = document.createElement('div');
t.className = 'toast'; t.className = 'toast';
+34 -2
View File
@@ -1,5 +1,5 @@
import { S } from '../state.js'; 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 { 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 { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem } from '../ui/components.js';
import { playQueue, dlSongLocal, toggleStar, parseArtists } from '../player/player.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) { export async function loadArtistDetail(artist) {
const nameEl = document.getElementById('artist-detail-name'); const nameEl = document.getElementById('artist-detail-name');
nameEl.textContent = artist.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(); document.getElementById('artist-back-btn').onclick = () => _navGoBack();
if (S.cfg.showRomaji) { if (S.cfg.showRomaji) {
fetchRomajiIndex() fetchRomajiIndex()
@@ -85,6 +88,8 @@ export async function loadArtistDetail(artist) {
const hasND = !!artist.id; const hasND = !!artist.id;
const knownItunes = artist.itunesArtistId || null; 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. // Fire all requests immediately so they run in parallel.
const imgP = hasND ? getArtistImage(artist.id) : Promise.resolve(null); 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 _normWords = s => new Set((s || '').toLowerCase().replace(/[^a-z0-9]/g, ' ').split(/\s+/).filter(Boolean));
const _queryWords = _normWords(artist.name); const _queryWords = _normWords(artist.name);
const iArtistResults = iArtistRes.status === 'fulfilled' ? (iArtistRes.value || []) : []; 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; if (_queryWords.size === 0) return true;
const rw = _normWords(r.artistName); const rw = _normWords(r.artistName);
return [..._queryWords].every(w => rw.has(w)); 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; 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. // Non-local (Deezer) artist has no Navidrome photo — fetch the Deezer artist picture for the hero.
if (!hasND && itunesArtistId && pfpWrap) { if (!hasND && itunesArtistId && pfpWrap) {
+12 -13
View File
@@ -1,5 +1,5 @@
import { S } from '../state.js'; 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 { api, itunesSearch, itunesLastError, itunesLookupAlbum, fetchRomajiIndex, dispArtistById } from '../api.js';
import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem, mkSection } from '../ui/components.js'; import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem, mkSection } from '../ui/components.js';
import { playPreview } from '../player/player.js'; import { playPreview } from '../player/player.js';
@@ -130,8 +130,9 @@ export async function doGlobalSearch(q) {
const s = mkSection('Künstler'); const s = mkSection('Künstler');
const row = document.createElement('div'); row.className = 'scroll-row'; const row = document.createElement('div'); row.className = 'scroll-row';
artists.forEach(a => { artists.forEach(a => {
const c = makeCard(a.artistName, 'Deezer Künstler', a.artworkUrl100 || null, '🎤'); const sub = a.fanCount ? `${fmtCount(a.fanCount)} Fans` : 'Deezer Künstler';
c.onclick = () => loadItunesArtistResults(a.artistId, a.artistName); const c = makeCard(a.artistName, sub, a.artworkUrl100 || null, '🎤');
c.onclick = () => loadItunesArtistResults(a.artistId, a.artistName, a.fanCount);
row.appendChild(c); row.appendChild(c);
if (a.artworkUrl100) return; // Deezer artists already carry a photo — no extra lookup if (a.artworkUrl100) return; // Deezer artists already carry a photo — no extra lookup
itunesSearch(a.artistName,'album',1).then(res => { itunesSearch(a.artistName,'album',1).then(res => {
@@ -178,14 +179,12 @@ export async function doGlobalSearch(q) {
if(!artists.length && !albums.length && !trackResults.length) { if(!artists.length && !albums.length && !trackResults.length) {
const err = itunesLastError(); const err = itunesLastError();
if (err) { if (err) {
// Distinguish "genuinely no results" from "Apple search is down" so the user knows where the // Distinguish "genuinely no results" from "search backend is down" so the user knows where
// problem is. Apple currently blocks the Search API (/search) with 403 from many server IPs. // the problem is.
const detail = err === 403 const detail = err === 'network'
? 'Apple blockt gerade die Such-API (Fehler 403). Das liegt an Apple, nicht an dir — Downloads per Direktlink funktionieren weiter.' ? 'Keine Verbindung zur Deezer-Such-API (Netzwerk/Backend). Läuft dein Backend?'
: err === 'network' : `Deezer-Suche nicht verfügbar (Fehler ${err}).`;
? 'Keine Verbindung zur Apple-Such-API (Netzwerk/Backend). Läuft dein Backend?' el.innerHTML = `<div class="empty-state"><div class="icon">⚠️</div><p>Deezer-Suche gerade nicht verfügbar</p><p style="font-size:12px;color:var(--text3);max-width:420px;margin:8px auto 0">${detail}</p></div>`;
: `Apple-Suche nicht verfügbar (Fehler ${err}).`;
el.innerHTML = `<div class="empty-state"><div class="icon">⚠️</div><p>Apple-Suche gerade nicht verfügbar</p><p style="font-size:12px;color:var(--text3);max-width:420px;margin:8px auto 0">${detail}</p></div>`;
} else { } else {
el.innerHTML = '<div class="empty-state"><div class="icon">🔍</div><p>Keine Ergebnisse gefunden</p></div>'; el.innerHTML = '<div class="empty-state"><div class="icon">🔍</div><p>Keine Ergebnisse gefunden</p></div>';
} }
@@ -215,8 +214,8 @@ export async function loadItunesAlbum(album) {
} catch(e) { toast('Fehler: ' + e.message); } } catch(e) { toast('Fehler: ' + e.message); }
} }
export function loadItunesArtistResults(artistId, artistName) { export function loadItunesArtistResults(artistId, artistName, fanCount) {
navigate('artist-detail', { id: null, name: artistName, itunesArtistId: artistId }); navigate('artist-detail', { id: null, name: artistName, itunesArtistId: artistId, itunesFanCount: fanCount });
} }
// ─── SEARCH HELPERS ─── // ─── SEARCH HELPERS ───
+2
View File
@@ -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-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-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-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){ @media(max-width:768px){
.artist-hero-bg{filter:brightness(0.75) saturate(0.85);transform:scale(1)} .artist-hero-bg{filter:brightness(0.75) saturate(0.85);transform:scale(1)}
.artist-hero-pfp-wrap{display:none} .artist-hero-pfp-wrap{display:none}