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

+34 -2
View File
@@ -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) {