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:
1 parent
f1eabf7099
commit
1aff19e319
10 files changed
+129
-44
No files matched your search
@@ -135,6 +135,9 @@ async function buildRomajiIndex(ndUrl, user, pass) {
|
|||||||
title: s.title,
|
title: s.title,
|
||||||
artist: s.artist || '',
|
artist: s.artist || '',
|
||||||
album: s.album || '',
|
album: s.album || '',
|
||||||
|
titleRomaji: romajiForStorage(s.title) || s.title,
|
||||||
|
artistRomaji: romajiForStorage(s.artist) || s.artist || '',
|
||||||
|
albumRomaji: romajiForStorage(s.album) || s.album || '',
|
||||||
coverArt: s.coverArt || '',
|
coverArt: s.coverArt || '',
|
||||||
duration: s.duration || 0,
|
duration: s.duration || 0,
|
||||||
albumId: s.albumId || '',
|
albumId: s.albumId || '',
|
||||||
|
|||||||
+37
-20
@@ -48,8 +48,11 @@ function buildVariantFilter(title) {
|
|||||||
return parts.length ? parts.join(' & ') : '';
|
return parts.length ? parts.join(' & ') : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadTrack({ jobId, artist, albumArtist, title, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, outputDir, durationMs, sourceUrl }) {
|
async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, outputDir, durationMs, sourceUrl }) {
|
||||||
const mainArtist = albumArtist || artist;
|
const mainArtist = albumArtist || artist;
|
||||||
|
|
||||||
|
// Folder / filename: romaji of the resolved artist (iTunes name if available — already in English,
|
||||||
|
// otherwise kuromoji). rawArtist (original Japanese) is only used for tags + YTM search.
|
||||||
const mainArtistRomaji = romajiForStorage(mainArtist) || mainArtist;
|
const mainArtistRomaji = romajiForStorage(mainArtist) || mainArtist;
|
||||||
const artistRomaji = romajiForStorage(artist) || artist;
|
const artistRomaji = romajiForStorage(artist) || artist;
|
||||||
const titleRomaji = romajiForStorage(title) || title;
|
const titleRomaji = romajiForStorage(title) || title;
|
||||||
@@ -73,11 +76,12 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t
|
|||||||
}
|
}
|
||||||
|
|
||||||
const outputTemplate = path.join(dir, `${fileBase}.%(ext)s`);
|
const outputTemplate = path.join(dir, `${fileBase}.%(ext)s`);
|
||||||
// Strip only Unicode symbol chars (☆ ★ ♥ etc.) for ytsearch — YouTube search chokes on them
|
// For YTM search: use the original (raw) artist when available — the YTM channel is registered
|
||||||
// and returns 0 results. Keep ASCII punctuation (!&-) so e.g. "N!GHT" stays "N!GHT" rather
|
// under the Japanese/original name, not the iTunes-resolved English name.
|
||||||
// than collapsing to "NGHT" which YouTube misreads as the word "night".
|
// rawArtist is undefined for non-Japanese tracks, so fall back to mainArtist.
|
||||||
const searchArtist = mainArtist.replace(/\p{S}/gu, '').trim();
|
const ytmArtist = (rawArtist || mainArtist).replace(/\p{S}/gu, '').trim();
|
||||||
const query = `${searchArtist} - ${title}`;
|
const searchArtist = ytmArtist;
|
||||||
|
const query = `${ytmArtist} - ${title}`;
|
||||||
|
|
||||||
const baseArgs = [
|
const baseArgs = [
|
||||||
'--extract-audio',
|
'--extract-audio',
|
||||||
@@ -112,8 +116,8 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t
|
|||||||
});
|
});
|
||||||
|
|
||||||
const YTM_SONGS_PARAM = 'EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D';
|
const YTM_SONGS_PARAM = 'EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D';
|
||||||
// Use original artist name (with special chars) for YTM URL — better search ranking than stripped version
|
// Use original (raw) artist for YTM URL — better ranking since channel is in original language
|
||||||
const ytmQuery = `${mainArtist} - ${title}`;
|
const ytmQuery = `${rawArtist || mainArtist} - ${title}`;
|
||||||
const ytmUrl = `https://music.youtube.com/search?q=${encodeURIComponent(ytmQuery)}¶ms=${YTM_SONGS_PARAM}`;
|
const ytmUrl = `https://music.youtube.com/search?q=${encodeURIComponent(ytmQuery)}¶ms=${YTM_SONGS_PARAM}`;
|
||||||
const expectedAudio = path.join(dir, `${fileBase}.${CONFIG.audioFmt}`);
|
const expectedAudio = path.join(dir, `${fileBase}.${CONFIG.audioFmt}`);
|
||||||
const hasAudio = () => fs.existsSync(expectedAudio);
|
const hasAudio = () => fs.existsSync(expectedAudio);
|
||||||
@@ -124,7 +128,7 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t
|
|||||||
await spawnYtdlp([sourceUrl, '--no-playlist', ...baseArgs]);
|
await spawnYtdlp([sourceUrl, '--no-playlist', ...baseArgs]);
|
||||||
const audioFile = hasAudio() ? expectedAudio : null;
|
const audioFile = hasAudio() ? expectedAudio : null;
|
||||||
if (!audioFile) { log(jobId, '⚠️ Download fehlgeschlagen'); return { success: true, dir }; }
|
if (!audioFile) { log(jobId, '⚠️ Download fehlgeschlagen'); return { success: true, dir }; }
|
||||||
return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title });
|
return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, rawArtist, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch YTM search result URLs as a flat list (no channel expansion) and return only
|
// Fetch YTM search result URLs as a flat list (no channel expansion) and return only
|
||||||
@@ -172,7 +176,7 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t
|
|||||||
// downloading a same-title song by a different artist (e.g. wrong "Moonlight" for N!GHT).
|
// downloading a same-title song by a different artist (e.g. wrong "Moonlight" for N!GHT).
|
||||||
// Dropped in later fallback steps so genuine mismatches (label channels etc.) still get found.
|
// Dropped in later fallback steps so genuine mismatches (label channels etc.) still get found.
|
||||||
const escRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
const artistFirstWord = mainArtist.split(/[\s,/]+/)[0];
|
const artistFirstWord = (rawArtist || mainArtist).split(/[\s,/]+/)[0];
|
||||||
const artistChannelPart = artistFirstWord.length >= 3
|
const artistChannelPart = artistFirstWord.length >= 3
|
||||||
? ` & channel ~= (?i)${escRe(artistFirstWord)}` : '';
|
? ` & channel ~= (?i)${escRe(artistFirstWord)}` : '';
|
||||||
|
|
||||||
@@ -287,11 +291,11 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t
|
|||||||
return { success: true, dir };
|
return { success: true, dir };
|
||||||
}
|
}
|
||||||
|
|
||||||
return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title });
|
return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, rawArtist, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared ffmpeg metadata embedding — used by both yt-dlp and squid paths
|
// Shared ffmpeg metadata embedding — used by both yt-dlp and squid paths
|
||||||
async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title }) {
|
async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, rawArtist, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title }) {
|
||||||
const coverPath = await downloadArtwork(artworkUrl, dir);
|
const coverPath = await downloadArtwork(artworkUrl, dir);
|
||||||
const hasCover = !!(coverPath && fs.existsSync(coverPath));
|
const hasCover = !!(coverPath && fs.existsSync(coverPath));
|
||||||
|
|
||||||
@@ -304,19 +308,32 @@ async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, ar
|
|||||||
if (hasCover) ffArgs.push('-i', coverPath);
|
if (hasCover) ffArgs.push('-i', coverPath);
|
||||||
ffArgs.push('-map', '0:a');
|
ffArgs.push('-map', '0:a');
|
||||||
if (hasCover) ffArgs.push('-map', '1:v');
|
if (hasCover) ffArgs.push('-map', '1:v');
|
||||||
const storedTitle = romajiForStorage(title) || title;
|
// Main tags: store original text (Japanese or whatever was passed in).
|
||||||
const storedArtist = romajiForStorage(navidromeArtist(artist)) || navidromeArtist(artist);
|
// rawArtist = original Spotify/source artist (may be Japanese); artist = iTunes-resolved (may be English).
|
||||||
const storedAlbumArtist = romajiForStorage(mainArtist.replace(/\s+&\s+/g, ', ')) || mainArtist.replace(/\s+&\s+/g, ', ');
|
// Use rawArtist for the stored tag so Apple Music / other apps see the authentic original.
|
||||||
const storedAlbum = album ? (romajiForStorage(album) || album) : null;
|
const tagArtist = navidromeArtist(rawArtist || artist);
|
||||||
|
const tagAlbumArtist = (rawArtist || mainArtist).replace(/\s+&\s+/g, ', ');
|
||||||
|
// Sort tags: romaji for display / sorting in apps that support them (TITLESORT, ARTISTSORT, etc.)
|
||||||
|
// artist is already the iTunes-resolved English name when available — use directly (more accurate
|
||||||
|
// than kuromoji). For non-iTunes tracks romajiForStorage acts as kuromoji fallback.
|
||||||
|
const sortTitle = romajiForStorage(title) || title;
|
||||||
|
const sortArtist = romajiForStorage(navidromeArtist(artist)) || navidromeArtist(artist);
|
||||||
|
const sortAlbumArtist = romajiForStorage(mainArtist.replace(/\s+&\s+/g, ', ')) || mainArtist.replace(/\s+&\s+/g, ', ');
|
||||||
|
const sortAlbum = album ? (romajiForStorage(album) || album) : null;
|
||||||
ffArgs.push(
|
ffArgs.push(
|
||||||
'-c', 'copy',
|
'-c', 'copy',
|
||||||
'-map_metadata', '-1',
|
'-map_metadata', '-1',
|
||||||
'-id3v2_version', '3',
|
'-id3v2_version', '3',
|
||||||
'-metadata', `title=${storedTitle}`,
|
'-metadata', `title=${title}`,
|
||||||
'-metadata', `artist=${storedArtist}`,
|
'-metadata', `artist=${tagArtist}`,
|
||||||
'-metadata', `album_artist=${storedAlbumArtist}`,
|
'-metadata', `album_artist=${tagAlbumArtist}`,
|
||||||
);
|
);
|
||||||
if (storedAlbum) ffArgs.push('-metadata', `album=${storedAlbum}`);
|
if (album) ffArgs.push('-metadata', `album=${album}`);
|
||||||
|
// Sort tags — only write when they differ from main (no-op for non-Japanese tracks)
|
||||||
|
if (sortTitle !== title) ffArgs.push('-metadata', `TITLESORT=${sortTitle}`);
|
||||||
|
if (sortArtist !== tagArtist) ffArgs.push('-metadata', `ARTISTSORT=${sortArtist}`);
|
||||||
|
if (sortAlbumArtist !== tagAlbumArtist) ffArgs.push('-metadata', `ALBUMARTISTSORT=${sortAlbumArtist}`);
|
||||||
|
if (sortAlbum && sortAlbum !== album) ffArgs.push('-metadata', `ALBUMSORT=${sortAlbum}`);
|
||||||
if (year) ffArgs.push('-metadata', `date=${String(year)}`);
|
if (year) ffArgs.push('-metadata', `date=${String(year)}`);
|
||||||
if (trackNum) ffArgs.push('-metadata', `track=${totalTracks ? `${trackNum}/${totalTracks}` : String(trackNum)}`);
|
if (trackNum) ffArgs.push('-metadata', `track=${totalTracks ? `${trackNum}/${totalTracks}` : String(trackNum)}`);
|
||||||
if (totalTracks) ffArgs.push('-metadata', `totaltracks=${String(totalTracks)}`);
|
if (totalTracks) ffArgs.push('-metadata', `totaltracks=${String(totalTracks)}`);
|
||||||
|
|||||||
@@ -373,7 +373,7 @@ app.post('/api/download', async (req, res) => {
|
|||||||
log(jobId, `📋 Album aus Spotify: "${meta.album}"`);
|
log(jobId, `📋 Album aus Spotify: "${meta.album}"`);
|
||||||
}
|
}
|
||||||
const finalArtist = meta.itunesArtistName || artist;
|
const finalArtist = meta.itunesArtistName || artist;
|
||||||
const result = await downloadTrack({ jobId, artist: finalArtist, title, ...meta, sourceUrl: sourceUrl || sp.sourceUrl || undefined });
|
const result = await downloadTrack({ jobId, artist: finalArtist, rawArtist: artist, title, ...meta, sourceUrl: sourceUrl || sp.sourceUrl || undefined });
|
||||||
if (result?.skipped) {
|
if (result?.skipped) {
|
||||||
updateJob(jobId, { status: 'done', progress: 100, message: 'Bereits vorhanden – übersprungen', skipped: true });
|
updateJob(jobId, { status: 'done', progress: 100, message: 'Bereits vorhanden – übersprungen', skipped: true });
|
||||||
} else {
|
} else {
|
||||||
@@ -447,10 +447,13 @@ app.post('/api/download', async (req, res) => {
|
|||||||
log(jobId, `📋 iTunes: "${trackArtist} – ${trackTitle}"`);
|
log(jobId, `📋 iTunes: "${trackArtist} – ${trackTitle}"`);
|
||||||
itunes = await lookupItunesTrackMeta(searchArtist, trackTitle);
|
itunes = await lookupItunesTrackMeta(searchArtist, trackTitle);
|
||||||
}
|
}
|
||||||
const resolvedArtist = itunes.itunesArtistName || trackArtist;
|
// Prefer iTunes-resolved name (from server lookup or frontend enrichment) for sort tags / folders.
|
||||||
|
// trackArtist stays as rawArtist for main tags and YTM search.
|
||||||
|
const resolvedArtist = itunes.itunesArtistName || track.itunesArtistName || trackArtist;
|
||||||
return downloadTrack({
|
return downloadTrack({
|
||||||
jobId,
|
jobId,
|
||||||
artist: resolvedArtist,
|
artist: resolvedArtist, // iTunes English name → used for sort tags + folder names
|
||||||
|
rawArtist: trackArtist, // original Spotify/Japanese name → used for main tag + YTM search
|
||||||
albumArtist: resolvedArtist,
|
albumArtist: resolvedArtist,
|
||||||
title: trackTitle,
|
title: trackTitle,
|
||||||
album: itunes.album || track.collectionName || track.album || '',
|
album: itunes.album || track.collectionName || track.album || '',
|
||||||
@@ -689,11 +692,18 @@ app.post('/api/edit-metadata', async (req, res) => {
|
|||||||
ffArgs.push('-map', '0:v?'); // '?' = optional, no error if file has no cover
|
ffArgs.push('-map', '0:v?'); // '?' = optional, no error if file has no cover
|
||||||
}
|
}
|
||||||
ffArgs.push('-c', 'copy', '-map_metadata', '-1', '-id3v2_version', '3');
|
ffArgs.push('-c', 'copy', '-map_metadata', '-1', '-id3v2_version', '3');
|
||||||
if (romajiTitle) ffArgs.push('-metadata', `title=${romajiTitle}`);
|
// Main tags: store original user input (may be Japanese — Apple Music / other apps see authentic text)
|
||||||
if (romajiArtist) ffArgs.push('-metadata', `artist=${romajiArtist}`);
|
if (title) ffArgs.push('-metadata', `title=${title}`);
|
||||||
const resolvedAlbumArtist = romajiAlbumArtist || (romajiArtist ? romajiArtist.split(';')[0].trim() : null);
|
if (normalArtist) ffArgs.push('-metadata', `artist=${normalArtist}`);
|
||||||
|
const resolvedAlbumArtist = normalAlbumArtist || (normalArtist ? normalArtist.split(';')[0].trim() : null);
|
||||||
if (resolvedAlbumArtist) ffArgs.push('-metadata', `album_artist=${resolvedAlbumArtist}`);
|
if (resolvedAlbumArtist) ffArgs.push('-metadata', `album_artist=${resolvedAlbumArtist}`);
|
||||||
if (romajiAlbum) ffArgs.push('-metadata', `album=${romajiAlbum}`);
|
if (album) ffArgs.push('-metadata', `album=${album}`);
|
||||||
|
// Sort tags: romaji versions for apps that use them for sorting / display
|
||||||
|
if (romajiTitle && romajiTitle !== title) ffArgs.push('-metadata', `TITLESORT=${romajiTitle}`);
|
||||||
|
if (romajiArtist && romajiArtist !== normalArtist) ffArgs.push('-metadata', `ARTISTSORT=${romajiArtist}`);
|
||||||
|
const resolvedRomajiAA = romajiAlbumArtist || (romajiArtist ? romajiArtist.split(';')[0].trim() : null);
|
||||||
|
if (resolvedRomajiAA && resolvedRomajiAA !== resolvedAlbumArtist) ffArgs.push('-metadata', `ALBUMARTISTSORT=${resolvedRomajiAA}`);
|
||||||
|
if (romajiAlbum && romajiAlbum !== album) ffArgs.push('-metadata', `ALBUMSORT=${romajiAlbum}`);
|
||||||
if (year) ffArgs.push('-metadata', `date=${year}`);
|
if (year) ffArgs.push('-metadata', `date=${year}`);
|
||||||
if (trackNum) ffArgs.push('-metadata', `track=${totalTracks ? `${trackNum}/${totalTracks}` : String(trackNum)}`);
|
if (trackNum) ffArgs.push('-metadata', `track=${totalTracks ? `${trackNum}/${totalTracks}` : String(trackNum)}`);
|
||||||
if (totalTracks) ffArgs.push('-metadata', `totaltracks=${String(totalTracks)}`);
|
if (totalTracks) ffArgs.push('-metadata', `totaltracks=${String(totalTracks)}`);
|
||||||
|
|||||||
@@ -656,6 +656,11 @@
|
|||||||
<div id="s-show-playcount" class="toggle" onclick="this.classList.toggle('on')"></div>
|
<div id="s-show-playcount" class="toggle" onclick="this.classList.toggle('on')"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hint" style="margin-top:6px">Zeigt Wiedergabezahl in Album- und Playlist-Ansichten. Künstlerseite zeigt sie immer.</div>
|
<div class="hint" style="margin-top:6px">Zeigt Wiedergabezahl in Album- und Playlist-Ansichten. Künstlerseite zeigt sie immer.</div>
|
||||||
|
<div class="edit-pl-toggle-row" style="margin-top:12px">
|
||||||
|
<span>Romaji anzeigen</span>
|
||||||
|
<div id="s-show-romaji" class="toggle" onclick="this.classList.toggle('on')"></div>
|
||||||
|
</div>
|
||||||
|
<div class="hint" style="margin-top:6px">Zeigt japanische Titel und Künstlernamen in Romaji (lateinische Umschrift). Originaltitel bleiben in den Datei-Tags erhalten.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3 style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
|
<h3 style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
|
||||||
|
|||||||
+36
-2
@@ -52,8 +52,15 @@ export async function ndAuth() {
|
|||||||
// ─── ROMAJI INDEX ───
|
// ─── ROMAJI INDEX ───
|
||||||
// Fetched once per session from the backend, cached in memory.
|
// Fetched once per session from the backend, cached in memory.
|
||||||
let _romajiIndex = null;
|
let _romajiIndex = null;
|
||||||
|
let _romajiMap = null; // Map<songId, { titleRomaji, artistRomaji, albumRomaji }>
|
||||||
let _romajiIndexFetch = null; // in-flight promise dedup
|
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() {
|
export async function fetchRomajiIndex() {
|
||||||
if (!S.cfg.backendUrl || !S.cfg.url || !S.cfg.user || !S.cfg.pass) return null;
|
if (!S.cfg.backendUrl || !S.cfg.url || !S.cfg.user || !S.cfg.pass) return null;
|
||||||
if (_romajiIndex) return _romajiIndex;
|
if (_romajiIndex) return _romajiIndex;
|
||||||
@@ -61,12 +68,39 @@ export async function fetchRomajiIndex() {
|
|||||||
_romajiIndexFetch = fetch(
|
_romajiIndexFetch = fetch(
|
||||||
`${S.cfg.backendUrl}/api/romaji-index?ndUrl=${encodeURIComponent(S.cfg.url)}&user=${encodeURIComponent(S.cfg.user)}&pass=${encodeURIComponent(S.cfg.pass)}`
|
`${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(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; });
|
.catch(() => { _romajiIndexFetch = null; return null; });
|
||||||
return _romajiIndexFetch;
|
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) {
|
export async function romajiConvert(texts) {
|
||||||
if (!S.cfg.backendUrl || !texts.length) return texts;
|
if (!S.cfg.backendUrl || !texts.length) return texts;
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
import { S } from './state.js';
|
import { S } from './state.js';
|
||||||
import { esc, formatDur } from './utils.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)
|
// Circular imports — safe (only used inside event handlers after module init)
|
||||||
import { playQueue, toggleStar, playPreview, parseArtists } from './player.js';
|
import { playQueue, toggleStar, playPreview, parseArtists } from './player.js';
|
||||||
import { showCtxMenu, openDlModal } from './ui.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.className = 'song-item' + (S.queue[S.qIdx]?.id === s.id ? ' playing' : '');
|
||||||
el.dataset.songId = s.id;
|
el.dataset.songId = s.id;
|
||||||
const cv = coverUrl(s.coverArt, 128);
|
const cv = coverUrl(s.coverArt, 128);
|
||||||
const artistParts = parseArtists(s);
|
const artistParts = dispArtistParts(s, parseArtists);
|
||||||
const artistHtml = artistParts.length
|
const artistHtml = artistParts.length
|
||||||
? artistParts.map(a => noArtistLink
|
? artistParts.map(a => noArtistLink
|
||||||
? `<span style="color:var(--text2)">${esc(a.name)}</span>`
|
? `<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>
|
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-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-info">
|
||||||
<div class="song-title">${esc(s.title)}</div>
|
<div class="song-title">${esc(dispTitle(s))}</div>
|
||||||
<div class="song-artist">${artistHtml}</div>
|
<div class="song-artist">${artistHtml}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="song-meta">
|
<div class="song-meta">
|
||||||
|
|||||||
+16
-8
@@ -1,6 +1,6 @@
|
|||||||
import { S } from './state.js';
|
import { S } from './state.js';
|
||||||
import { esc, formatDur, toast } from './utils.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)
|
// views.js/ui.js circular imports — safe (only used inside function bodies)
|
||||||
import { loadRecommendations } from './views.js';
|
import { loadRecommendations } from './views.js';
|
||||||
import { updateLyricsHighlight, onNpSongChange } from './nowplaying.js';
|
import { updateLyricsHighlight, onNpSongChange } from './nowplaying.js';
|
||||||
@@ -451,13 +451,13 @@ export function parseArtists(s) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function updatePlayerUI(s, _skipMediaSession = false) {
|
export function updatePlayerUI(s, _skipMediaSession = false) {
|
||||||
document.getElementById('player-title').textContent = s.title;
|
document.getElementById('player-title').textContent = dispTitle(s);
|
||||||
const artistParts = parseArtists(s);
|
const artistParts = dispArtistParts(s, parseArtists);
|
||||||
document.getElementById('player-artist-name').innerHTML = artistParts.length
|
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>`)
|
? 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>')
|
.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 art = document.getElementById('player-art');
|
||||||
const cv = coverUrl(s.coverArt, 256);
|
const cv = coverUrl(s.coverArt, 256);
|
||||||
const existingImg = art.querySelector('img');
|
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>';
|
else cover.innerHTML = cv ? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">` : '<div class="no-art">♪</div>';
|
||||||
}
|
}
|
||||||
_crossfadeNpBg(cv);
|
_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');
|
const an = document.getElementById('np-artist-name');
|
||||||
if(an) {
|
if(an) {
|
||||||
const parts = parseArtists(s);
|
const parts = dispArtistParts(s, parseArtists);
|
||||||
an.innerHTML = parts.length
|
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>`)
|
? 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>')
|
.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();
|
syncNpStar();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -994,7 +994,7 @@ export function renderQueue() {
|
|||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'queue-item' + (i === S.qIdx ? ' active' : '');
|
item.className = 'queue-item' + (i === S.qIdx ? ' active' : '');
|
||||||
const cv = coverUrl(s.coverArt, 128);
|
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(); };
|
item.onclick = () => { S.qIdx = i; playCurrent(); };
|
||||||
el.appendChild(item);
|
el.appendChild(item);
|
||||||
});
|
});
|
||||||
@@ -1008,6 +1008,14 @@ export function toggleQueue() {
|
|||||||
_syncOverlayState();
|
_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 ─────────────────────────────────────────────────────────────
|
// ─── DOWNLOAD ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function dlSongLocal(s) {
|
export function dlSongLocal(s) {
|
||||||
|
|||||||
+3
-2
@@ -295,8 +295,9 @@ async function _loadSpotifyTrackArtwork(tracks, listEl) {
|
|||||||
if (match.trackExplicitness) t.trackExplicitness = match.trackExplicitness;
|
if (match.trackExplicitness) t.trackExplicitness = match.trackExplicitness;
|
||||||
if (match.previewUrl) t.previewUrl = match.previewUrl;
|
if (match.previewUrl) t.previewUrl = match.previewUrl;
|
||||||
if (match.trackTimeMillis) t.durationMs = match.trackTimeMillis;
|
if (match.trackTimeMillis) t.durationMs = match.trackTimeMillis;
|
||||||
// Use iTunes canonical English/romaji artist name (Spotify may have Japanese or wrong order)
|
// Store iTunes canonical name separately — keeps original Spotify/Japanese in artistName
|
||||||
if (match.artistName) t.artistName = match.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);
|
if (window._spotifyResolved?.tracks?.[idx]) Object.assign(window._spotifyResolved.tracks[idx], t);
|
||||||
const url = match.artworkUrl100 || match.artworkUrl60 || '';
|
const url = match.artworkUrl100 || match.artworkUrl60 || '';
|
||||||
if (url) _setArt(artEls[idx], url);
|
if (url) _setArt(artEls[idx], url);
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
// Shared mutable state — imported as { S } in every module
|
// Shared mutable state — imported as { S } in every module
|
||||||
export const S = {
|
export const S = {
|
||||||
cfg: { url: '', user: '', pass: '', lastfmKey: '', backendUrl: '', showPlayCount: false },
|
cfg: { url: '', user: '', pass: '', lastfmKey: '', backendUrl: '', showPlayCount: false, showRomaji: false },
|
||||||
|
|
||||||
// Playback
|
// Playback
|
||||||
queue: [],
|
queue: [],
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { esc, formatDur, toast } from './utils.js';
|
|||||||
import { api, coverUrl, ndAuth, fetchRomajiIndex, romajiConvert } from './api.js';
|
import { api, coverUrl, ndAuth, fetchRomajiIndex, romajiConvert } from './api.js';
|
||||||
import { THEMES, applyTheme, getCurrentThemeId } from './themes.js';
|
import { THEMES, applyTheme, getCurrentThemeId } from './themes.js';
|
||||||
// Circular imports — safe (only used inside function bodies)
|
// 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';
|
import { navigate, loadPlaylists } from './views.js';
|
||||||
|
|
||||||
// ─── SIDEBAR ───
|
// ─── SIDEBAR ───
|
||||||
@@ -60,6 +60,7 @@ export function openSettings() {
|
|||||||
document.getElementById('s-lastfm').value = S.cfg.lastfmKey || '';
|
document.getElementById('s-lastfm').value = S.cfg.lastfmKey || '';
|
||||||
document.getElementById('s-backend').value = S.cfg.backendUrl || '';
|
document.getElementById('s-backend').value = S.cfg.backendUrl || '';
|
||||||
document.getElementById('s-show-playcount').classList.toggle('on', !!S.cfg.showPlayCount);
|
document.getElementById('s-show-playcount').classList.toggle('on', !!S.cfg.showPlayCount);
|
||||||
|
document.getElementById('s-show-romaji').classList.toggle('on', !!S.cfg.showRomaji);
|
||||||
_renderThemePicker();
|
_renderThemePicker();
|
||||||
document.getElementById('settings-modal').classList.add('open');
|
document.getElementById('settings-modal').classList.add('open');
|
||||||
checkBackend();
|
checkBackend();
|
||||||
@@ -93,8 +94,14 @@ export function saveSettings() {
|
|||||||
S.cfg.lastfmKey = document.getElementById('s-lastfm').value.trim();
|
S.cfg.lastfmKey = document.getElementById('s-lastfm').value.trim();
|
||||||
S.cfg.backendUrl = document.getElementById('s-backend').value.trim().replace(/\/$/,'');
|
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));
|
localStorage.setItem('ns_cfg', JSON.stringify(S.cfg));
|
||||||
closeSettings(); toast('✅ Gespeichert');
|
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() {
|
export async function checkBackend() {
|
||||||
const dot = document.getElementById('backend-status-dot');
|
const dot = document.getElementById('backend-status-dot');
|
||||||
|
|||||||
Reference in new issue
Block a user