diff --git a/backend/lib/metadata.js b/backend/lib/metadata.js index 317f89d..8d0a906 100644 --- a/backend/lib/metadata.js +++ b/backend/lib/metadata.js @@ -128,7 +128,12 @@ async function lookupItunesTrackMeta(artist, title) { // 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')]); 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) return {}; // JP genre names are localized (e.g. イージーリスニング); prefer US genre, map JP→EN as fallback diff --git a/backend/lib/squid.js b/backend/lib/squid.js index 121ae44..6e267b0 100644 --- a/backend/lib/squid.js +++ b/backend/lib/squid.js @@ -11,6 +11,7 @@ const { downloadArtwork, fetchLyrics, findExistingTrack, } = require('./metadata'); const { embedMetadata } = require('./ytdlp'); +const { romajiForStorage } = require('./romaji'); const SQUID_BASE = 'https://qobuz.squid.wtf'; @@ -222,14 +223,18 @@ async function downloadUrlToFile(url, destPath) { // Download a single track via squid.wtf / 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 safeArtist = sanitize(mainArtist.split(',')[0].trim()); - const safeAlbum = sanitize(album || 'Singles'); + // Use romaji for folder/filename — same as ytdlp path so paths are consistent + 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); fs.mkdirSync(dir, { recursive: true }); - const safeTitle = sanitize(title); + const safeTitle = sanitize(titleRomaji); const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : ''; const ext = squidExt(); @@ -278,7 +283,7 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, - artist, mainArtist, album, year, trackNum, totalTracks, + rawArtist, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl: finalArtwork, explicit, title, }); diff --git a/backend/lib/ytdlp.js b/backend/lib/ytdlp.js index 056a337..6ac9787 100644 --- a/backend/lib/ytdlp.js +++ b/backend/lib/ytdlp.js @@ -7,9 +7,20 @@ const { sanitize, navidromeArtist, downloadArtwork, findExistingTrack, } = 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) { + // 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 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']; @@ -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 }) { 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, // otherwise kuromoji). rawArtist (original Japanese) is only used for tags + YTM search. const mainArtistRomaji = romajiForStorage(mainArtist) || mainArtist; @@ -83,7 +109,6 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb const searchArtist = ytmArtist; const escRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const JP_CHARS = /[ぁ-んァ-ン一-龯]/; const variantFilter = buildVariantFilter(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 // almost never in kanji, so the filter would reject every valid result. 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)}` : ''; // 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. // 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. - const hasJpTitle = JP_CHARS.test(title); + const hasJpTitle = JP_RE.test(title); const albumWords = (album && album !== 'Singles' && album !== 'Unknown') ? album.split(/\s+/).filter(w => /^[A-Za-z0-9]/.test(w)).slice(0, 2).join(' ') : '';