fix: repair Japanese song download pipeline
Five separate root causes fixed: 1. buildTitleKeywords (ytdlp.js): For Japanese titles \w strips all kana/kanji leaving no keywords. Now uses the first whitespace- delimited segment as a literal regex keyword so --match-filter title ~= requires the YTM video to contain the Japanese text. 2. Tokenizer wait (ytdlp.js): If kuromoji hasn't finished loading when a Japanese download starts, romajiForStorage returns the original Japanese text unchanged — producing Japanese folder/filenames and skipping sort tags. Now waits up to 30 s for the tokenizer. 3. titleOnlyMatch guard (metadata.js): iTunes titleOnlyMatch (no artist check) was used as a fallback for all searches. For Japanese titles/artists this matched wrong US songs with identical titles (common in anime OSTs). Now titleOnlyMatch is skipped when the search title or artist contains Japanese characters. 4. squid.js rawArtist: rawArtist was not in squid's signature and never forwarded to embedMetadata, so Qobuz-downloaded J-Pop tracks stored the iTunes-resolved English name as artist instead of the Japanese original. Fixed by accepting and forwarding rawArtist. 5. squid.js romaji paths: Folder and filename used raw artist/title with no romaji conversion, creating Japanese-named paths inconsistent with the yt-dlp path. Now uses romajiForStorage same as ytdlp.js. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
52451f5086
commit
63ff8920bf
3 files changed
+45
-10
No files matched your search
@@ -128,7 +128,12 @@ async function lookupItunesTrackMeta(artist, title) {
|
|||||||
// Fetch both in parallel — JP for correct Japanese titles/artwork, US for English genre names
|
// Fetch both in parallel — JP for correct Japanese titles/artwork, US for English genre names
|
||||||
const [jpResults, usResults] = await Promise.all([fetchItunes('jp'), fetchItunes('us')]);
|
const [jpResults, usResults] = await Promise.all([fetchItunes('jp'), fetchItunes('us')]);
|
||||||
let match = strictMatch(jpResults);
|
let match = strictMatch(jpResults);
|
||||||
let usMatch = strictMatch(usResults) || titleOnlyMatch(usResults);
|
// titleOnlyMatch (no artist check) is too permissive for Japanese titles/artists —
|
||||||
|
// common anime/OST titles exist on both JP and US stores under different artists.
|
||||||
|
// Only use it as a fallback when neither title nor artist contains Japanese characters.
|
||||||
|
const JP_RE = /[-ヿ㐀-䶿一-鿿]/;
|
||||||
|
const isJpSearch = JP_RE.test(normT) || JP_RE.test(normA);
|
||||||
|
let usMatch = strictMatch(usResults) || (!isJpSearch && titleOnlyMatch(usResults));
|
||||||
if (!match) match = usMatch;
|
if (!match) match = usMatch;
|
||||||
if (!match) return {};
|
if (!match) return {};
|
||||||
// JP genre names are localized (e.g. イージーリスニング); prefer US genre, map JP→EN as fallback
|
// JP genre names are localized (e.g. イージーリスニング); prefer US genre, map JP→EN as fallback
|
||||||
|
|||||||
+10
-5
@@ -11,6 +11,7 @@ const {
|
|||||||
downloadArtwork, fetchLyrics, findExistingTrack,
|
downloadArtwork, fetchLyrics, findExistingTrack,
|
||||||
} = require('./metadata');
|
} = require('./metadata');
|
||||||
const { embedMetadata } = require('./ytdlp');
|
const { embedMetadata } = require('./ytdlp');
|
||||||
|
const { romajiForStorage } = require('./romaji');
|
||||||
|
|
||||||
const SQUID_BASE = 'https://qobuz.squid.wtf';
|
const SQUID_BASE = 'https://qobuz.squid.wtf';
|
||||||
|
|
||||||
@@ -222,14 +223,18 @@ async function downloadUrlToFile(url, destPath) {
|
|||||||
|
|
||||||
// Download a single track via squid.wtf / Qobuz.
|
// Download a single track via squid.wtf / Qobuz.
|
||||||
// Returns { success: true, dir, skipped? } on success, null if track not found on Qobuz.
|
// Returns { success: true, dir, skipped? } on success, null if track not found on Qobuz.
|
||||||
async function downloadTrack({ jobId, artist, albumArtist, title, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, outputDir, durationMs }) {
|
async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, outputDir, durationMs }) {
|
||||||
const mainArtist = albumArtist || artist;
|
const mainArtist = albumArtist || artist;
|
||||||
const safeArtist = sanitize(mainArtist.split(',')[0].trim());
|
// Use romaji for folder/filename — same as ytdlp path so paths are consistent
|
||||||
const safeAlbum = sanitize(album || 'Singles');
|
const mainArtistRomaji = romajiForStorage(mainArtist) || mainArtist;
|
||||||
|
const titleRomaji = romajiForStorage(title) || title;
|
||||||
|
const albumRomaji = album ? (romajiForStorage(album) || album) : null;
|
||||||
|
const safeArtist = sanitize(mainArtistRomaji.split(/[,;&]/)[0].trim());
|
||||||
|
const safeAlbum = sanitize(albumRomaji || 'Singles');
|
||||||
const dir = path.join(outputDir || CONFIG.musicPath, safeArtist, safeAlbum);
|
const dir = path.join(outputDir || CONFIG.musicPath, safeArtist, safeAlbum);
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
const safeTitle = sanitize(title);
|
const safeTitle = sanitize(titleRomaji);
|
||||||
const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
|
const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
|
||||||
const ext = squidExt();
|
const ext = squidExt();
|
||||||
|
|
||||||
@@ -278,7 +283,7 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t
|
|||||||
|
|
||||||
return embedMetadata({
|
return embedMetadata({
|
||||||
jobId, audioFile, dir, trackPrefix, safeTitle,
|
jobId, audioFile, dir, trackPrefix, safeTitle,
|
||||||
artist, mainArtist, album, year, trackNum, totalTracks,
|
rawArtist, artist, mainArtist, album, year, trackNum, totalTracks,
|
||||||
discNum, discTotal, compilation, genre,
|
discNum, discTotal, compilation, genre,
|
||||||
artworkUrl: finalArtwork, explicit, title,
|
artworkUrl: finalArtwork, explicit, title,
|
||||||
});
|
});
|
||||||
|
|||||||
+29
-4
@@ -7,9 +7,20 @@ const {
|
|||||||
sanitize, navidromeArtist,
|
sanitize, navidromeArtist,
|
||||||
downloadArtwork, findExistingTrack,
|
downloadArtwork, findExistingTrack,
|
||||||
} = require('./metadata');
|
} = require('./metadata');
|
||||||
const { romajiForStorage } = require('./romaji');
|
const { romajiForStorage, isTokenizerReady } = require('./romaji');
|
||||||
|
|
||||||
|
// Matches hiragana, katakana, CJK — used to detect Japanese content
|
||||||
|
const JP_RE = /[ぁ-んァ-ン一-龯]/;
|
||||||
|
|
||||||
function buildTitleKeywords(title) {
|
function buildTitleKeywords(title) {
|
||||||
|
// For Japanese titles: \w strips all kana/kanji, leaving nothing useful.
|
||||||
|
// Instead use the first whitespace-delimited segment as a literal keyword so
|
||||||
|
// the YTM --match-filter requires the video title to contain it.
|
||||||
|
if (JP_RE.test(title)) {
|
||||||
|
const firstWord = (title.split(/[\s ]/)[0] || '').slice(0, 12);
|
||||||
|
const escaped = firstWord.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
return escaped ? [escaped] : [];
|
||||||
|
}
|
||||||
const VARIANT_WORDS = new Set(['sped','speedup','slowed','reverb','nightcore','karaoke','parody','remix','cover','acoustic','instrumental']);
|
const VARIANT_WORDS = new Set(['sped','speedup','slowed','reverb','nightcore','karaoke','parody','remix','cover','acoustic','instrumental']);
|
||||||
const STOPWORDS = new Set(['the','a','an','and','or','of','in','on','at','to','for','feat','ft','with']);
|
const STOPWORDS = new Set(['the','a','an','and','or','of','in','on','at','to','for','feat','ft','with']);
|
||||||
const MIX_QUALIFIERS = ['radio', 'extended', 'club', 'vip', 'original', 'edit'];
|
const MIX_QUALIFIERS = ['radio', 'extended', 'club', 'vip', 'original', 'edit'];
|
||||||
@@ -51,6 +62,21 @@ function buildVariantFilter(title) {
|
|||||||
async function downloadTrack({ jobId, artist, rawArtist, 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;
|
||||||
|
|
||||||
|
// If the title or artist contains Japanese characters, wait for kuromoji to finish loading.
|
||||||
|
// Without it romajiForStorage returns the original text unchanged, producing Japanese
|
||||||
|
// folder/filenames and preventing sort tags from being written.
|
||||||
|
if (JP_RE.test(title) || JP_RE.test(rawArtist || artist || '')) {
|
||||||
|
if (!isTokenizerReady()) {
|
||||||
|
log(jobId, '⏳ Warte auf kuromoji-Tokenizer…');
|
||||||
|
await new Promise(resolve => {
|
||||||
|
const t0 = Date.now();
|
||||||
|
const iv = setInterval(() => {
|
||||||
|
if (isTokenizerReady() || Date.now() - t0 > 30000) { clearInterval(iv); resolve(); }
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Folder / filename: romaji of the resolved artist (iTunes name if available — already in English,
|
// 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.
|
// otherwise kuromoji). rawArtist (original Japanese) is only used for tags + YTM search.
|
||||||
const mainArtistRomaji = romajiForStorage(mainArtist) || mainArtist;
|
const mainArtistRomaji = romajiForStorage(mainArtist) || mainArtist;
|
||||||
@@ -83,7 +109,6 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
|||||||
const searchArtist = ytmArtist;
|
const searchArtist = ytmArtist;
|
||||||
|
|
||||||
const escRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
const JP_CHARS = /[ぁ-んァ-ン一-龯]/;
|
|
||||||
|
|
||||||
const variantFilter = buildVariantFilter(title);
|
const variantFilter = buildVariantFilter(title);
|
||||||
const titleKws = buildTitleKeywords(title);
|
const titleKws = buildTitleKeywords(title);
|
||||||
@@ -95,14 +120,14 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
|||||||
// Skip when artistFirstWord is non-ASCII (Japanese kanji/kana): YouTube channel names are
|
// Skip when artistFirstWord is non-ASCII (Japanese kanji/kana): YouTube channel names are
|
||||||
// almost never in kanji, so the filter would reject every valid result.
|
// almost never in kanji, so the filter would reject every valid result.
|
||||||
const artistFirstWord = (rawArtist || mainArtist).split(/[\s,/]+/)[0];
|
const artistFirstWord = (rawArtist || mainArtist).split(/[\s,/]+/)[0];
|
||||||
const artistChannelPart = (artistFirstWord.length >= 3 && !JP_CHARS.test(artistFirstWord))
|
const artistChannelPart = (artistFirstWord.length >= 3 && !JP_RE.test(artistFirstWord))
|
||||||
? ` & channel ~= (?i)${escRe(artistFirstWord)}` : '';
|
? ` & channel ~= (?i)${escRe(artistFirstWord)}` : '';
|
||||||
|
|
||||||
// When the title is Japanese (keywords stripped to nothing) OR when we have an album that
|
// When the title is Japanese (keywords stripped to nothing) OR when we have an album that
|
||||||
// provides better search context, include the album's first two words in the search query.
|
// provides better search context, include the album's first two words in the search query.
|
||||||
// This helps YTM rank the official OST/album track above fan covers and romaji-subtitle uploads.
|
// This helps YTM rank the official OST/album track above fan covers and romaji-subtitle uploads.
|
||||||
// Only use album words that are ASCII (romaji/English) — Japanese album words don't help YTM ranking.
|
// Only use album words that are ASCII (romaji/English) — Japanese album words don't help YTM ranking.
|
||||||
const hasJpTitle = JP_CHARS.test(title);
|
const hasJpTitle = JP_RE.test(title);
|
||||||
const albumWords = (album && album !== 'Singles' && album !== 'Unknown')
|
const albumWords = (album && album !== 'Singles' && album !== 'Unknown')
|
||||||
? album.split(/\s+/).filter(w => /^[A-Za-z0-9]/.test(w)).slice(0, 2).join(' ')
|
? album.split(/\s+/).filter(w => /^[A-Za-z0-9]/.test(w)).slice(0, 2).join(' ')
|
||||||
: '';
|
: '';
|
||||||
|
|||||||
Reference in new issue
Block a user