feat: keep Japanese in main tags, romaji in sort tags + frontend toggle

- Downloads now store original Japanese in TITLE/ARTIST/ALBUM tags
  so Apple Music, iTunes, YTM etc. all see the authentic original text
- Sort tags (TITLESORT/ARTISTSORT/ALBUMSORT) written with romaji for
  apps that use them for sorting/display
- iTunes-resolved English artist name used for ARTISTSORT and folder names
  when available (more accurate than kuromoji for known artists)
- rawArtist parameter added to downloadTrack so YTM search query uses
  the original Japanese channel name (fixes wrong song downloads)
- spotify.js: artistName preserved as original; itunesArtistName stored
  separately so the Spotify importer shows Japanese titles
- romaji index now includes titleRomaji/artistRomaji/albumRomaji fields
- Frontend: showRomaji setting (Settings > Bibliothek) — when on, player
  bar, now-playing, queue, and song lists all show romaji via index lookup
- refreshDisplayMode() updates all live UI when the toggle is saved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-25 14:58:53 +02:00
1 parent f1eabf7099
commit 1aff19e319
10 files changed
+140 -55

No files matched your search

+36 -2
View File
@@ -52,8 +52,15 @@ export async function ndAuth() {
// ─── ROMAJI INDEX ───
// Fetched once per session from the backend, cached in memory.
let _romajiIndex = null;
let _romajiMap = null; // Map<songId, { titleRomaji, artistRomaji, albumRomaji }>
let _romajiIndexFetch = null; // in-flight promise dedup
function _buildRomajiMap(idx) {
const m = new Map();
if (Array.isArray(idx)) idx.forEach(e => m.set(String(e.id), e));
return m;
}
export async function fetchRomajiIndex() {
if (!S.cfg.backendUrl || !S.cfg.url || !S.cfg.user || !S.cfg.pass) return null;
if (_romajiIndex) return _romajiIndex;
@@ -61,12 +68,39 @@ export async function fetchRomajiIndex() {
_romajiIndexFetch = fetch(
`${S.cfg.backendUrl}/api/romaji-index?ndUrl=${encodeURIComponent(S.cfg.url)}&user=${encodeURIComponent(S.cfg.user)}&pass=${encodeURIComponent(S.cfg.pass)}`
).then(r => r.ok ? r.json() : null)
.then(idx => { _romajiIndex = idx; _romajiIndexFetch = null; return idx; })
.then(idx => { _romajiIndex = idx; _romajiMap = _buildRomajiMap(idx); _romajiIndexFetch = null; return idx; })
.catch(() => { _romajiIndexFetch = null; return null; });
return _romajiIndexFetch;
}
export function invalidateRomajiIndex() { _romajiIndex = null; }
export function invalidateRomajiIndex() { _romajiIndex = null; _romajiMap = null; }
// Synchronous lookup — returns index entry for a song ID, or null if not in index.
export function romajiLookup(id) { return _romajiMap?.get(String(id)) ?? null; }
// Display helpers — return romaji when showRomaji is on and the song is in the index,
// otherwise fall back to the original Navidrome values.
export function dispTitle(s) {
if (!S.cfg.showRomaji) return s.title || '';
return romajiLookup(s.id)?.titleRomaji || s.title || '';
}
export function dispArtist(s) {
if (!S.cfg.showRomaji) return s.artist || '';
return romajiLookup(s.id)?.artistRomaji || s.artist || '';
}
export function dispAlbum(s) {
if (!S.cfg.showRomaji) return s.album || '';
return romajiLookup(s.id)?.albumRomaji || s.album || '';
}
// Returns [{name, id}] like parseArtists but with romaji display names when showRomaji is on.
// Multi-artist romaji is collapsed into one entry since we can't reliably split the converted string.
export function dispArtistParts(s, parseArtistsFn) {
const parts = parseArtistsFn(s);
if (!S.cfg.showRomaji) return parts;
const info = romajiLookup(s.id);
if (!info?.artistRomaji) return parts;
return [{ name: info.artistRomaji, id: parts[0]?.id || '' }];
}
export async function romajiConvert(texts) {
if (!S.cfg.backendUrl || !texts.length) return texts;
+3 -3
View File
@@ -1,6 +1,6 @@
import { S } from './state.js';
import { esc, formatDur } from './utils.js';
import { coverUrl } from './api.js';
import { coverUrl, dispTitle, dispArtistParts, dispAlbum } from './api.js';
// Circular imports — safe (only used inside event handlers after module init)
import { playQueue, toggleStar, playPreview, parseArtists } from './player.js';
import { showCtxMenu, openDlModal } from './ui.js';
@@ -33,7 +33,7 @@ export function makeSongItem(s, idx, list, { noArtistLink = false } = {}) {
el.className = 'song-item' + (S.queue[S.qIdx]?.id === s.id ? ' playing' : '');
el.dataset.songId = s.id;
const cv = coverUrl(s.coverArt, 128);
const artistParts = parseArtists(s);
const artistParts = dispArtistParts(s, parseArtists);
const artistHtml = artistParts.length
? artistParts.map(a => noArtistLink
? `<span style="color:var(--text2)">${esc(a.name)}</span>`
@@ -47,7 +47,7 @@ export function makeSongItem(s, idx, list, { noArtistLink = false } = {}) {
el.innerHTML = `<div class="song-num">${idx+1}</div>
<div class="song-thumb">${cv ? `<img src="${cv}" loading="lazy" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>♪</div>'">` : '<div class="no-art">♪</div>'}</div>
<div class="song-info">
<div class="song-title">${esc(s.title)}</div>
<div class="song-title">${esc(dispTitle(s))}</div>
<div class="song-artist">${artistHtml}</div>
</div>
<div class="song-meta">
+16 -8
View File
@@ -1,6 +1,6 @@
import { S } from './state.js';
import { esc, formatDur, toast } from './utils.js';
import { api, streamUrl, coverUrl, downloadUrl } from './api.js';
import { api, streamUrl, coverUrl, downloadUrl, dispTitle, dispArtist, dispArtistParts, dispAlbum } from './api.js';
// views.js/ui.js circular imports — safe (only used inside function bodies)
import { loadRecommendations } from './views.js';
import { updateLyricsHighlight, onNpSongChange } from './nowplaying.js';
@@ -451,13 +451,13 @@ export function parseArtists(s) {
}
export function updatePlayerUI(s, _skipMediaSession = false) {
document.getElementById('player-title').textContent = s.title;
const artistParts = parseArtists(s);
document.getElementById('player-title').textContent = dispTitle(s);
const artistParts = dispArtistParts(s, parseArtists);
document.getElementById('player-artist-name').innerHTML = artistParts.length
? artistParts.map(a => `<span style="cursor:pointer" onclick="s_goArtist(event,'${esc(a.name)}','${a.id||''}')">${esc(a.name)}</span>`)
.join('<span style="color:var(--text3)"> • </span>')
: '—';
document.getElementById('player-album-name').textContent = s.album || '';
document.getElementById('player-album-name').textContent = dispAlbum(s);
const art = document.getElementById('player-art');
const cv = coverUrl(s.coverArt, 256);
const existingImg = art.querySelector('img');
@@ -518,16 +518,16 @@ export function _syncNpDirect(s) {
else cover.innerHTML = cv ? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">` : '<div class="no-art">♪</div>';
}
_crossfadeNpBg(cv);
const t = document.getElementById('np-title'); if(t) t.textContent = s.title || '—';
const t = document.getElementById('np-title'); if(t) t.textContent = dispTitle(s) || '—';
const an = document.getElementById('np-artist-name');
if(an) {
const parts = parseArtists(s);
const parts = dispArtistParts(s, parseArtists);
an.innerHTML = parts.length
? parts.map(a => `<span style="cursor:pointer" onclick="closeNowPlaying();s_goArtist(event,'${esc(a.name)}','${a.id||''}')">${esc(a.name)}</span>`)
.join('<span style="opacity:.5"> • </span>')
: '—';
}
const aln = document.getElementById('np-album-name'); if(aln) aln.textContent = s.album || '';
const aln = document.getElementById('np-album-name'); if(aln) aln.textContent = dispAlbum(s);
syncNpStar();
}
@@ -994,7 +994,7 @@ export function renderQueue() {
const item = document.createElement('div');
item.className = 'queue-item' + (i === S.qIdx ? ' active' : '');
const cv = coverUrl(s.coverArt, 128);
item.innerHTML = `<div class="qi-idx">${i+1}</div><div class="qi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="qi-info"><div class="qi-title">${esc(s.title)}</div><div class="qi-artist">${esc(s.artist||'')}</div></div>`;
item.innerHTML = `<div class="qi-idx">${i+1}</div><div class="qi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="qi-info"><div class="qi-title">${esc(dispTitle(s))}</div><div class="qi-artist">${esc(dispArtist(s))}</div></div>`;
item.onclick = () => { S.qIdx = i; playCurrent(); };
el.appendChild(item);
});
@@ -1008,6 +1008,14 @@ export function toggleQueue() {
_syncOverlayState();
}
// Called after showRomaji is toggled — re-renders all live player UI with new display mode.
export function refreshDisplayMode() {
_renderedQueueKey = ''; // force queue re-render
if (S.queueOpen) renderQueue();
const s = S.queue[S.qIdx];
if (s) { updatePlayerUI(s, true); _syncNpDirect(s); }
}
// ─── DOWNLOAD ─────────────────────────────────────────────────────────────
export function dlSongLocal(s) {
+3 -2
View File
@@ -295,8 +295,9 @@ async function _loadSpotifyTrackArtwork(tracks, listEl) {
if (match.trackExplicitness) t.trackExplicitness = match.trackExplicitness;
if (match.previewUrl) t.previewUrl = match.previewUrl;
if (match.trackTimeMillis) t.durationMs = match.trackTimeMillis;
// Use iTunes canonical English/romaji artist name (Spotify may have Japanese or wrong order)
if (match.artistName) t.artistName = match.artistName;
// Store iTunes canonical name separately — keeps original Spotify/Japanese in artistName
// so the UI shows the original and YTM search uses the original channel name.
if (match.artistName) t.itunesArtistName = match.artistName;
if (window._spotifyResolved?.tracks?.[idx]) Object.assign(window._spotifyResolved.tracks[idx], t);
const url = match.artworkUrl100 || match.artworkUrl60 || '';
if (url) _setArt(artEls[idx], url);
+1 -1
View File
@@ -1,6 +1,6 @@
// Shared mutable state — imported as { S } in every module
export const S = {
cfg: { url: '', user: '', pass: '', lastfmKey: '', backendUrl: '', showPlayCount: false },
cfg: { url: '', user: '', pass: '', lastfmKey: '', backendUrl: '', showPlayCount: false, showRomaji: false },
// Playback
queue: [],
+9 -2
View File
@@ -3,7 +3,7 @@ import { esc, formatDur, toast } from './utils.js';
import { api, coverUrl, ndAuth, fetchRomajiIndex, romajiConvert } from './api.js';
import { THEMES, applyTheme, getCurrentThemeId } from './themes.js';
// Circular imports — safe (only used inside function bodies)
import { playQueue, dlSongLocal, toggleStar, togglePlay, renderQueue, syncNpStar, updateStarBtn, npToggleStar, playCurrent, _syncNpDirect, setPlayIcon, nativeInsertAt } from './player.js';
import { playQueue, dlSongLocal, toggleStar, togglePlay, renderQueue, syncNpStar, updateStarBtn, npToggleStar, playCurrent, _syncNpDirect, setPlayIcon, nativeInsertAt, refreshDisplayMode } from './player.js';
import { navigate, loadPlaylists } from './views.js';
// ─── SIDEBAR ───
@@ -60,6 +60,7 @@ export function openSettings() {
document.getElementById('s-lastfm').value = S.cfg.lastfmKey || '';
document.getElementById('s-backend').value = S.cfg.backendUrl || '';
document.getElementById('s-show-playcount').classList.toggle('on', !!S.cfg.showPlayCount);
document.getElementById('s-show-romaji').classList.toggle('on', !!S.cfg.showRomaji);
_renderThemePicker();
document.getElementById('settings-modal').classList.add('open');
checkBackend();
@@ -92,9 +93,15 @@ export function saveSettings() {
if(pass) S.cfg.pass = pass;
S.cfg.lastfmKey = document.getElementById('s-lastfm').value.trim();
S.cfg.backendUrl = document.getElementById('s-backend').value.trim().replace(/\/$/,'');
S.cfg.showPlayCount = document.getElementById('s-show-playcount').classList.contains('on');
S.cfg.showPlayCount = document.getElementById('s-show-playcount').classList.contains('on');
const romajiChanged = S.cfg.showRomaji !== document.getElementById('s-show-romaji').classList.contains('on');
S.cfg.showRomaji = document.getElementById('s-show-romaji').classList.contains('on');
localStorage.setItem('ns_cfg', JSON.stringify(S.cfg));
closeSettings(); toast('✅ Gespeichert');
if (romajiChanged) {
// Pre-load the index so display is instant, then refresh all live UI
fetchRomajiIndex().then(() => refreshDisplayMode());
}
}
export async function checkBackend() {
const dot = document.getElementById('backend-status-dot');