fix: playlist matching for romaji-stored Japanese tracks + punctuation cleanup
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
c7bebd5691
commit
f1eabf7099
4 files changed
+48
-5
No files matched your search
@@ -90,8 +90,14 @@ function romajiForStorage(text) {
|
|||||||
const needsSpace = /[a-zA-Z0-9]$/.test(parts[i - 1]) && /^[a-zA-Z]/.test(part);
|
const needsSpace = /[a-zA-Z0-9]$/.test(parts[i - 1]) && /^[a-zA-Z]/.test(part);
|
||||||
return acc + (needsSpace ? ' ' : '') + 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
|
// 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 ────────────────────────────────────────────────────────────
|
// ─── INDEX BUILDER ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -109,6 +109,14 @@ app.delete('/api/romaji-index', (req, res) => {
|
|||||||
res.json({ ok: true });
|
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=...
|
// GET /api/genre-playcounts?ndUrl=...&user=...&pass=...
|
||||||
// Returns { [genre]: totalPlayCount } sorted by plays desc, cached for 20 min.
|
// Returns { [genre]: totalPlayCount } sorted by plays desc, cached for 20 min.
|
||||||
const _genrePlayCache = new Map();
|
const _genrePlayCache = new Map();
|
||||||
|
|||||||
+14
@@ -68,6 +68,20 @@ export async function fetchRomajiIndex() {
|
|||||||
|
|
||||||
export function invalidateRomajiIndex() { _romajiIndex = null; }
|
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 ───
|
// ─── ITUNES ───
|
||||||
// Route through backend proxy to avoid CORS; fall back to direct if no backend configured.
|
// Route through backend proxy to avoid CORS; fall back to direct if no backend configured.
|
||||||
function _itunesBase() {
|
function _itunesBase() {
|
||||||
|
|||||||
@@ -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, coverUrl, ndAuth, fetchRomajiIndex } 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 } 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.
|
// 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 hasJp = tracks.some(t => JP_RE.test(t.trackName) || JP_RE.test(t.artistName));
|
||||||
const romajiIdx = hasJp ? await fetchRomajiIndex().catch(() => null) : null;
|
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;
|
if (!t.trackName) continue;
|
||||||
const missingLabel = t.artistName ? t.artistName + ' – ' + t.trackName : t.trackName;
|
const missingLabel = t.artistName ? t.artistName + ' – ' + t.trackName : t.trackName;
|
||||||
try {
|
try {
|
||||||
@@ -586,12 +591,22 @@ async function _createNdPlaylist(tracks, name) {
|
|||||||
const artistMatch = s => firstWord(s.artist) === firstWord(firstArtist || t.artistName);
|
const artistMatch = s => firstWord(s.artist) === firstWord(firstArtist || t.artistName);
|
||||||
let match = songs.find(s => titleMatch(s) && artistMatch(s))
|
let match = songs.find(s => titleMatch(s) && artistMatch(s))
|
||||||
|| songs.find(s => titleMatch(s));
|
|| songs.find(s => titleMatch(s));
|
||||||
// Fallback: search romaji index by original Japanese title
|
// Fallback 1: search romaji index by original Japanese title
|
||||||
// (Navidrome search3 doesn't index Japanese text properly)
|
// (for old songs that still have Japanese in Navidrome)
|
||||||
if (!match && romajiIdx && JP_RE.test(t.trackName)) {
|
if (!match && romajiIdx && JP_RE.test(t.trackName)) {
|
||||||
const jpEntry = romajiIdx.find(e => norm(e.title) === norm(t.trackName));
|
const jpEntry = romajiIdx.find(e => norm(e.title) === norm(t.trackName));
|
||||||
if (jpEntry) match = { id: jpEntry.id };
|
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);
|
if (match) songIds.push(match.id);
|
||||||
else missing.push(missingLabel);
|
else missing.push(missingLabel);
|
||||||
} catch { missing.push(missingLabel); }
|
} catch { missing.push(missingLabel); }
|
||||||
|
|||||||
Reference in new issue
Block a user