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

+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 });
}