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:
1 parent
b788fedec3
commit
02960cf886
6 files changed
+204
-7
No files matched your search
@@ -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;
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+40
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user