From f1eabf70995f1a40043ca5b9a9c37927c4c7a59e Mon Sep 17 00:00:00 2001 From: Honey Date: Mon, 25 May 2026 01:20:37 +0200 Subject: [PATCH] fix: playlist matching for romaji-stored Japanese tracks + punctuation cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clean Japanese punctuation (、。・) in romajiForStorage so it doesn't pass through - Add POST /api/romaji-convert endpoint for batch title conversion - Add romajiConvert() to api.js - Add third fallback in _createNdPlaylist: convert Japanese title to romaji and search Navidrome with it (for songs already downloaded with romaji tags) Co-Authored-By: Claude Sonnet 4.6 --- backend/lib/romaji.js | 8 +++++++- backend/novasonic-server.js | 8 ++++++++ src/api.js | 14 ++++++++++++++ src/ui.js | 23 +++++++++++++++++++---- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/backend/lib/romaji.js b/backend/lib/romaji.js index 7b6efef..cf75ec8 100644 --- a/backend/lib/romaji.js +++ b/backend/lib/romaji.js @@ -90,8 +90,14 @@ function romajiForStorage(text) { const needsSpace = /[a-zA-Z0-9]$/.test(parts[i - 1]) && /^[a-zA-Z]/.test(part); return acc + (needsSpace ? ' ' : '') + part; }, ''); + // Clean up Japanese punctuation that passes through as-is + const cleaned = joined + .replace(/[、。]/g, ' ') // Japanese comma/period → space + .replace(/[・・]/g, ' ') // middle dot → space + .replace(/[「」『』【】〔〕〈〉《》]/g, '') // Japanese brackets → remove + .replace(/(/g, '(').replace(/)/g, ')'); // fullwidth parens → ASCII // Title-case: capitalize first lowercase letter of each word, preserve existing uppercase - return joined.replace(/\b([a-z])/g, c => c.toUpperCase()).replace(/\s+/g, ' ').trim(); + return cleaned.replace(/\b([a-z])/g, c => c.toUpperCase()).replace(/\s+/g, ' ').trim(); } // ─── INDEX BUILDER ──────────────────────────────────────────────────────────── diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index 306c31f..a0031f0 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -109,6 +109,14 @@ app.delete('/api/romaji-index', (req, res) => { 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; + if (!Array.isArray(texts)) return res.status(400).json({ error: 'texts must be array' }); + if (!isTokenizerReady()) return res.status(503).json({ error: 'tokenizer not ready' }); + res.json({ results: texts.map(t => romajiForStorage(t) || t) }); +}); + // GET /api/genre-playcounts?ndUrl=...&user=...&pass=... // Returns { [genre]: totalPlayCount } sorted by plays desc, cached for 20 min. const _genrePlayCache = new Map(); diff --git a/src/api.js b/src/api.js index 0f2b344..0b3f97b 100644 --- a/src/api.js +++ b/src/api.js @@ -68,6 +68,20 @@ export async function fetchRomajiIndex() { export function invalidateRomajiIndex() { _romajiIndex = null; } +export async function romajiConvert(texts) { + if (!S.cfg.backendUrl || !texts.length) return texts; + try { + const r = await fetch(`${S.cfg.backendUrl}/api/romaji-convert`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ texts }), + }); + if (!r.ok) return texts; + const { results } = await r.json(); + return results; + } catch { return texts; } +} + // ─── ITUNES ─── // Route through backend proxy to avoid CORS; fall back to direct if no backend configured. function _itunesBase() { diff --git a/src/ui.js b/src/ui.js index d7d86d7..29629f4 100644 --- a/src/ui.js +++ b/src/ui.js @@ -1,6 +1,6 @@ import { S } from './state.js'; import { esc, formatDur, toast } from './utils.js'; -import { api, coverUrl, ndAuth, fetchRomajiIndex } from './api.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'; @@ -565,7 +565,12 @@ async function _createNdPlaylist(tracks, name) { // doesn't handle Japanese text, so we need the index as a fallback. const hasJp = tracks.some(t => JP_RE.test(t.trackName) || JP_RE.test(t.artistName)); const romajiIdx = hasJp ? await fetchRomajiIndex().catch(() => null) : null; - for (const t of tracks) { + // Pre-convert all Japanese titles to romaji in one batch — needed for songs that were + // already downloaded with romaji tags (they're no longer in the romaji index). + const jpTitles = tracks.map(t => (hasJp && JP_RE.test(t.trackName)) ? t.trackName : ''); + const romajiTitles = hasJp ? await romajiConvert(jpTitles).catch(() => jpTitles) : jpTitles; + const normR = s => (s || '').toLowerCase().replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim(); + for (const [idx, t] of tracks.entries()) { if (!t.trackName) continue; const missingLabel = t.artistName ? t.artistName + ' – ' + t.trackName : t.trackName; try { @@ -586,12 +591,22 @@ async function _createNdPlaylist(tracks, name) { const artistMatch = s => firstWord(s.artist) === firstWord(firstArtist || t.artistName); let match = songs.find(s => titleMatch(s) && artistMatch(s)) || songs.find(s => titleMatch(s)); - // Fallback: search romaji index by original Japanese title - // (Navidrome search3 doesn't index Japanese text properly) + // Fallback 1: search romaji index by original Japanese title + // (for old songs that still have Japanese in Navidrome) if (!match && romajiIdx && JP_RE.test(t.trackName)) { const jpEntry = romajiIdx.find(e => norm(e.title) === norm(t.trackName)); if (jpEntry) match = { id: jpEntry.id }; } + // Fallback 2: search Navidrome with romaji-converted title + // (for songs downloaded after romaji changes — stored with romaji tags) + if (!match && JP_RE.test(t.trackName) && romajiTitles[idx]) { + const rTitle = romajiTitles[idx]; + try { + const r3 = await api('search3', '&query=' + encodeURIComponent(rTitle) + '&songCount=20&albumCount=0&artistCount=0'); + const s3 = r3.searchResult3?.song || []; + match = s3.find(s => normR(s.title) === normR(rTitle)) || null; + } catch { /* ignore */ } + } if (match) songIds.push(match.id); else missing.push(missingLabel); } catch { missing.push(missingLabel); }