From 02960cf886c66bdf2cd70dde1e38760724fc64d4 Mon Sep 17 00:00:00 2001 From: Honey Date: Wed, 12 Aug 2026 21:38:42 +0200 Subject: [PATCH] feat(artist): pin a local artist to a specific Deezer artist (same-name fix, shared) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/novasonic-server.js | 41 ++++++++++++++++ public/sw.js | 2 +- src/api.js | 40 ++++++++++++++++ src/main.js | 5 +- src/ui/artistPin.js | 94 +++++++++++++++++++++++++++++++++++++ src/views/detail.js | 29 ++++++++++-- 6 files changed, 204 insertions(+), 7 deletions(-) create mode 100644 src/ui/artistPin.js diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index c33fce2..c3d1a98 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -233,6 +233,47 @@ app.delete('/api/romaji-overrides/:kanji', (req, res) => { res.json({ ok: true }); }); +// ─── ARTIST OVERRIDES (pin a local artist → a specific Deezer artist; shared) ── +// Fixes same-name collisions where NovaSonic would otherwise guess the wrong Deezer artist +// (fans / top songs / albums / image). Keyed by the Navidrome artist id. +const _artistOverridesFile = path.join(CONFIG.dataPath, 'deezer-artist-overrides.json'); +function _loadArtistOverrides() { + try { return JSON.parse(fs.readFileSync(_artistOverridesFile, 'utf8')); } catch { return {}; } +} +function _saveArtistOverrides(map) { + try { + fs.mkdirSync(CONFIG.dataPath, { recursive: true }); + fs.writeFileSync(_artistOverridesFile, JSON.stringify(map, null, 2), 'utf8'); + } catch (e) { console.error('Failed to save artist overrides:', e.message); } +} + +// GET — full map { ndArtistId: { deezerArtistId, name, image, fanCount } } +app.get('/api/artist-overrides', (_req, res) => res.json(_loadArtistOverrides())); + +// POST — upsert a pin { ndArtistId, deezerArtistId, name, image, fanCount } +app.post('/api/artist-overrides', (req, res) => { + const { ndArtistId, deezerArtistId, name, image, fanCount } = req.body || {}; + if (!ndArtistId || !deezerArtistId) return res.status(400).json({ error: 'ndArtistId and deezerArtistId required' }); + const map = _loadArtistOverrides(); + map[String(ndArtistId)] = { + deezerArtistId: String(deezerArtistId), + name: name || '', + image: image || '', + fanCount: (fanCount ?? null), + }; + _saveArtistOverrides(map); + res.json({ ok: true }); +}); + +// DELETE /api/artist-overrides/:ndArtistId — remove a pin +app.delete('/api/artist-overrides/:ndArtistId', (req, res) => { + const id = decodeURIComponent(req.params.ndArtistId); + const map = _loadArtistOverrides(); + delete map[id]; + _saveArtistOverrides(map); + res.json({ ok: true }); +}); + // POST /api/romaji-convert — convert an array of strings to romaji app.post('/api/romaji-convert', (req, res) => { const { texts } = req.body; diff --git a/public/sw.js b/public/sw.js index c00359f..a0454ed 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'ns-v73'; +const CACHE = 'ns-v74'; 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 9b92d90..d2cc7e0 100644 --- a/src/api.js +++ b/src/api.js @@ -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(); diff --git a/src/main.js b/src/main.js index 30a9320..eb21feb 100644 --- a/src/main.js +++ b/src/main.js @@ -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, diff --git a/src/ui/artistPin.js b/src/ui/artistPin.js new file mode 100644 index 0000000..b6ed00f --- /dev/null +++ b/src/ui/artistPin.js @@ -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 = ` + `; + 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 + ? `` + : ''; + body.innerHTML = clearBtn + `
Suche…
`; + + let results = []; + try { results = (await deezerSearch(_ctx.ndName, 'musicArtist', 8)) || []; } catch {} + + const rows = results.map(r => ` +
+
+ ${r.artworkUrl100 ? `` : ''} +
+
+
${esc(r.artistName || '—')}
+
${r.fanCount != null ? fmtCount(r.fanCount) + ' Fans' : 'Deezer'}
+
+
`).join(''); + + body.innerHTML = clearBtn + (rows || `
Keine Deezer-Künstler gefunden.
`); + 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 }); +} diff --git a/src/views/detail.js b/src/views/detail.js index e1f7324..147efdf 100644 --- a/src/views/detail.js +++ b/src/views/detail.js @@ -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 = '
'; 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);