feat(artist): pin a local artist to a specific Deezer artist (same-name fix, shared)

Fixes same-name collisions where NovaSonic guessed the wrong Deezer artist (e.g.
'Nana' → a random DJ): fans, top songs, missing albums and the hero image were all
off. A '⚙ Falscher Künstler?' button on the artist page opens a picker (same-name
Deezer candidates with image + fan count); the chosen mapping is stored SERVER-SIDE
(shared across users, like the romaji overrides) in /data/deezer-artist-overrides.json.

Once pinned, the artist page short-circuits the name search to the pinned Deezer id
(drives albums/top-songs/fans) and overrides the hero image with the pinned artist's
Deezer picture (NovaSonic-side, so it works even for tag-merged same-name artists —
Navidrome can't disambiguate them without an MBID). New endpoints
GET/POST/DELETE /api/artist-overrides; api.js load/get/set/remove; loaded at startup.
SW cache -> ns-v74. Needs on-device runtime test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-08-12 21:38:42 +02:00
1 parent b788fedec3
commit 02960cf886
6 files changed
+204 -7

No files matched your search

+40
View File
@@ -194,6 +194,46 @@ export async function removeRomajiOverride(kanji) {
} catch (e) { console.warn('removeRomajiOverride failed:', e); }
}
// ─── ARTIST OVERRIDES (pin a local artist → a specific Deezer artist; shared) ───
// Fixes same-name collisions on the artist page (fans / top songs / albums / image).
let _artistOverridesCache = null;
export function getArtistOverrides() { return _artistOverridesCache || {}; }
export function getArtistOverride(ndArtistId) { return (_artistOverridesCache || {})[String(ndArtistId)] || null; }
export async function loadArtistOverrides() {
if (!S.cfg?.backendUrl) { _artistOverridesCache = {}; return; }
try {
const r = await fetch(`${S.cfg.backendUrl}/api/artist-overrides`);
_artistOverridesCache = r.ok ? await r.json() : {};
} catch { _artistOverridesCache = {}; }
}
export async function setArtistOverride(ndArtistId, deezer) {
if (!S.cfg?.backendUrl || !ndArtistId || !deezer?.deezerArtistId) return;
const entry = {
deezerArtistId: String(deezer.deezerArtistId),
name: deezer.name || '',
image: deezer.image || '',
fanCount: deezer.fanCount ?? null,
};
try {
await fetch(`${S.cfg.backendUrl}/api/artist-overrides`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ndArtistId: String(ndArtistId), ...entry }),
});
if (!_artistOverridesCache) _artistOverridesCache = {};
_artistOverridesCache[String(ndArtistId)] = entry;
} catch (e) { console.warn('setArtistOverride failed:', e); }
}
export async function removeArtistOverride(ndArtistId) {
if (!S.cfg?.backendUrl || !ndArtistId) return;
try {
await fetch(`${S.cfg.backendUrl}/api/artist-overrides/${encodeURIComponent(ndArtistId)}`, { method: 'DELETE' });
if (_artistOverridesCache) delete _artistOverridesCache[String(ndArtistId)];
} catch (e) { console.warn('removeArtistOverride failed:', e); }
}
export function dispArtistById(id, name) {
if (!S.cfg.showRomaji) return name || '';
const ov = getRomajiOverrides();
+4 -1
View File
@@ -1,9 +1,10 @@
import { S } from './state.js';
import { toast } from './utils.js';
import { api, fetchRomajiIndex, loadRomajiOverrides } from './api.js';
import { api, fetchRomajiIndex, loadRomajiOverrides, loadArtistOverrides } from './api.js';
import { injectPWA } from './pwa.js';
import { initPlayLog } from './player/scrobbleLog.js';
import { openStats, closeStats } from './ui/stats.js';
import { closeArtistPin, clearArtistPin } from './ui/artistPin.js';
import { loadSavedTheme } from './themes.js';
import { initScrollRows } from './ui/components.js';
import {
@@ -111,6 +112,7 @@ async function startApp() {
}
initNotifBell();
initPlayLog(); // flush any queued plays to the scrobble service (offline-safe)
loadArtistOverrides().catch(() => {}); // pinned same-name artist → Deezer mappings (shared)
// Wait for overrides before restoring player bar so disp* functions have the cache ready
loadRomajiOverrides().catch(() => {}).then(() => { _restoreLastPlayed(); });
if(localStorage.getItem('ns_radio')==='1') { S.radioMode=true; _applyRadioBtn(); }
@@ -334,6 +336,7 @@ Object.assign(window, {
openSettings, closeSettings, saveSettings, confirmDisconnect, checkBackend,
openSidebar, closeSidebar, switchNavMode, showCtxMenu,
openStats, closeStats,
closeArtistPin, clearArtistPin,
// Views
loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist,
plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres,
+94
View File
@@ -0,0 +1,94 @@
// ─── Artist pin picker ───────────────────────────────────────────────────────
// Opened from the artist page ("⚙ Falscher Künstler?"). Lets the user pin a local
// (Navidrome) artist to a specific Deezer artist, fixing same-name collisions where
// NovaSonic would otherwise guess the wrong one (fans / top songs / albums / image).
// The mapping is stored server-side (shared across users) — see api.js.
import { deezerSearch, setArtistOverride, removeArtistOverride, getArtistOverride } from '../api.js';
import { esc, fmtCount } from '../utils.js';
let _overlay = null;
let _ctx = null; // { ndArtistId, ndName }
export function openArtistPin(ndArtistId, ndName) {
_ctx = { ndArtistId: String(ndArtistId), ndName: ndName || '' };
if (!_overlay) _build();
_overlay.classList.add('open');
document.body.style.overflow = 'hidden';
_render();
}
export function closeArtistPin() {
if (_overlay) _overlay.classList.remove('open');
document.body.style.overflow = '';
}
function _build() {
_overlay = document.createElement('div');
_overlay.className = 'modal-overlay';
_overlay.id = 'artist-pin-overlay';
_overlay.addEventListener('click', e => { if (e.target === _overlay) closeArtistPin(); });
_overlay.innerHTML = `
<div class="modal-card" style="max-width:520px;width:calc(100vw - 32px);max-height:86vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border2);border-radius:18px;box-shadow:0 40px 80px #0009;overflow:hidden">
<div style="display:flex;align-items:center;gap:10px;padding:16px 18px;border-bottom:1px solid var(--border)">
<span style="font-size:18px">⚙</span>
<div style="flex:1;min-width:0">
<div style="font-size:16px;font-weight:800">Richtigen Künstler wählen</div>
<div id="artist-pin-sub" style="font-size:12px;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></div>
</div>
<button onclick="closeArtistPin()" style="background:none;border:none;color:var(--text2);font-size:18px;cursor:pointer;padding:6px 10px">✕</button>
</div>
<div id="artist-pin-body" style="padding:12px 14px;overflow-y:auto"></div>
</div>`;
document.body.appendChild(_overlay);
}
async function _render() {
_overlay.querySelector('#artist-pin-sub').textContent = `Für „${_ctx.ndName}"`;
const body = _overlay.querySelector('#artist-pin-body');
const cur = getArtistOverride(_ctx.ndArtistId);
const clearBtn = cur
? `<button onclick="clearArtistPin()" style="width:100%;padding:10px;margin-bottom:10px;border-radius:9px;border:1px solid var(--border);background:transparent;color:var(--danger);font-family:var(--font);font-weight:600;cursor:pointer">Pin entfernen (aktuell: ${esc(cur.name || 'gepinnt')})</button>`
: '';
body.innerHTML = clearBtn + `<div style="text-align:center;color:var(--text2);padding:24px 0">Suche…</div>`;
let results = [];
try { results = (await deezerSearch(_ctx.ndName, 'musicArtist', 8)) || []; } catch {}
const rows = results.map(r => `
<div class="artist-pin-cand" data-id="${esc(r.artistId)}" data-name="${esc(r.artistName || '')}" data-img="${esc(r.artworkUrl100 || '')}" data-fans="${r.fanCount ?? ''}"
style="display:flex;align-items:center;gap:12px;padding:8px;border-radius:10px;cursor:pointer" onmouseover="this.style.background='var(--surface2)'" onmouseout="this.style.background='none'">
<div style="width:48px;height:48px;border-radius:50%;background:var(--surface2);overflow:hidden;flex-shrink:0">
${r.artworkUrl100 ? `<img src="${esc(r.artworkUrl100)}" loading="lazy" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">` : ''}
</div>
<div style="flex:1;min-width:0">
<div style="font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(r.artistName || '—')}</div>
<div style="font-size:12px;color:var(--text2)">${r.fanCount != null ? fmtCount(r.fanCount) + ' Fans' : 'Deezer'}</div>
</div>
</div>`).join('');
body.innerHTML = clearBtn + (rows || `<div style="text-align:center;color:var(--text2);padding:24px 0">Keine Deezer-Künstler gefunden.</div>`);
body.querySelectorAll('.artist-pin-cand').forEach(el => el.addEventListener('click', () => _pick(el.dataset)));
}
async function _pick(ds) {
await setArtistOverride(_ctx.ndArtistId, {
deezerArtistId: ds.id,
name: ds.name,
image: ds.img,
fanCount: ds.fans !== '' ? Number(ds.fans) : null,
});
closeArtistPin();
_reloadArtist();
}
export async function clearArtistPin() {
if (_ctx) await removeArtistOverride(_ctx.ndArtistId);
closeArtistPin();
_reloadArtist();
}
async function _reloadArtist() {
const { navigate } = await import('../views/views.js');
navigate('artist-detail', { id: _ctx.ndArtistId, name: _ctx.ndName });
}
+24 -5
View File
@@ -1,6 +1,7 @@
import { S } from '../state.js';
import { esc, toast, fmtCount } from '../utils.js';
import { api, coverUrl, deezerSearch, deezerLookupAlbum, deezerLookupArtistSongs, deezerLookupArtistAlbums, deezerArtistImage, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames, ensureArtistSortMap } from '../api.js';
import { api, coverUrl, deezerSearch, deezerLookupAlbum, deezerLookupArtistSongs, deezerLookupArtistAlbums, deezerArtistImage, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames, ensureArtistSortMap, getArtistOverride } from '../api.js';
import { openArtistPin } from '../ui/artistPin.js';
import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem } from '../ui/components.js';
import { playQueue, dlSongLocal, toggleStar, parseArtists } from '../player/player.js';
import { showCtxMenu, openDlModal } from '../ui/ui.js';
@@ -87,12 +88,30 @@ export async function loadArtistDetail(artist) {
if(albEl) albEl.innerHTML = '<div class="spinner"></div>';
const hasND = !!artist.id;
const knownDeezer = artist.deezerArtistId || null;
const knownFans = artist.deezerFanCount ?? null;
if (knownFans) _showFans(knownFans); // from the global-search card — show immediately
// Manual pin (same-name fix): use the pinned Deezer artist for id/fans/image instead of guessing.
const _pin = hasND ? getArtistOverride(artist.id) : null;
const knownDeezer = _pin?.deezerArtistId || artist.deezerArtistId || null;
const knownFans = _pin?.fanCount ?? artist.deezerFanCount ?? null;
if (knownFans) _showFans(knownFans); // from the global-search card / pin — show immediately
// "Falscher Künstler?" pin button (local artists only) — fix a wrong same-name Deezer match.
if (hasND) {
const infoEl = hero.querySelector('.artist-hero-info');
infoEl?.querySelector('.artist-pin-btn')?.remove();
if (infoEl) {
const pinBtn = document.createElement('button');
pinBtn.className = 'artist-pin-btn';
pinBtn.textContent = _pin ? '⚙ Künstler gepinnt' : '⚙ Falscher Künstler?';
pinBtn.title = 'Richtigen Deezer-Künstler wählen';
pinBtn.style.cssText = 'margin-top:8px;padding:5px 12px;border-radius:999px;border:1px solid var(--border2);background:rgba(0,0,0,.35);color:#fff;font-family:var(--font);font-size:12px;font-weight:600;cursor:pointer';
pinBtn.onclick = () => openArtistPin(artist.id, artist.name);
infoEl.appendChild(pinBtn);
}
}
// Fire all requests immediately so they run in parallel.
const imgP = hasND ? getArtistImage(artist.id) : Promise.resolve(null);
const imgP = _pin?.image ? Promise.resolve(_pin.image)
: hasND ? getArtistImage(artist.id) : Promise.resolve(null);
const albumP = hasND ? api('getArtist', `&id=${artist.id}`) : Promise.resolve(null);
const infoP = hasND ? api('getArtistInfo2',`&id=${artist.id}&count=8`) : Promise.resolve(null);
const topP = hasND ? api('getTopSongs', `&artist=${encodeURIComponent(artist.name)}&count=10`) : Promise.resolve(null);