diff --git a/backend/lib/ytdlp.js b/backend/lib/ytdlp.js index 98f99c4..50b817a 100644 --- a/backend/lib/ytdlp.js +++ b/backend/lib/ytdlp.js @@ -100,7 +100,9 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t }); const YTM_SONGS_PARAM = 'EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D'; - const ytmUrl = `https://music.youtube.com/search?q=${encodeURIComponent(query)}¶ms=${YTM_SONGS_PARAM}`; + // Use original artist name (with special chars) for YTM URL — better search ranking than stripped version + const ytmQuery = `${mainArtist} - ${title}`; + const ytmUrl = `https://music.youtube.com/search?q=${encodeURIComponent(ytmQuery)}¶ms=${YTM_SONGS_PARAM}`; const expectedAudio = path.join(dir, `${trackPrefix}${safeTitle}.${CONFIG.audioFmt}`); const hasAudio = () => fs.existsSync(expectedAudio); const tryYtdlp = async (args) => { await spawnYtdlp(args); }; @@ -155,10 +157,18 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t const variantPart = variantFilter ? ` & ${variantFilter}` : ''; const titleKwPart = titleKws.length ? titleKws.map(k => ` & title ~= (?i)${k}`).join('') : ''; - const mf = (dur, variant) => { + // Soft channel check: first word of original artist name used in early cascade steps to avoid + // 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. + const escRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const artistFirstWord = mainArtist.split(/[\s,/]+/)[0]; + const artistChannelPart = artistFirstWord.length >= 3 + ? ` & channel ~= (?i)${escRe(artistFirstWord)}` : ''; + + const mf = (dur, variant, strict = false) => { const durPart = dur ? `duration > ${dur.min} & duration < ${dur.max} & ` : ''; return ['--match-filter', - `${durPart}!is_live${titleKwPart}${variant ? variantPart : ''}`]; + `${durPart}!is_live${titleKwPart}${strict ? artistChannelPart : ''}${variant ? variantPart : ''}`]; }; const PI_FILTERED = ['--playlist-items', '1-5', '--max-downloads', '1']; @@ -171,20 +181,20 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t const dur = { min: Math.max(30, durationSec - margin), max: durationSec + margin }; log(jobId, `🔍 Suche YT Music (~${durationSec}s ±${margin}s): "${query}"`); - // 1. YTM top-5 — pre-filtered URLs (no channel expansion), duration + title + variant filter - await tryYtmSearch(5, mf(dur, true)); + // 1. YTM top-5 — strict: duration + title + variant + channel check + await tryYtmSearch(5, mf(dur, true, true)); if (!hasAudio()) { - // 2. YT top-5 — ytsearch always returns videos (no channels), safe to use directly + // 2. YT top-5 — strict: same filters including channel check log(jobId, '⚠️ Kein YTM-Treffer — Fallback zu YouTube…'); - await tryYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(dur, true), ...baseArgs]); + await tryYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(dur, true, true), ...baseArgs]); } if (!hasAudio()) { - // 3. YTM top-10 — wider net + // 3. YTM top-10 — no channel check (label channels, etc.) log(jobId, '⚠️ Erweitere Suche auf top 10 mit Dauerbeschränkung…'); await tryYtmSearch(10, mf(dur, true)); } if (!hasAudio()) { - // 4. YT top-10 + // 4. YT top-10 — no channel check await tryYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(dur, true), ...baseArgs]); } if (!hasAudio()) { @@ -216,28 +226,36 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t } } else { log(jobId, `🔍 Suche YT Music: "${query}"`); - // 1. YTM top-5 — pre-filtered, title + variant - await tryYtmSearch(5, mf(null, true)); + // 1. YTM top-5 — strict: title + variant + channel check + await tryYtmSearch(5, mf(null, true, true)); if (!hasAudio()) { - // 2. YT top-5 + // 2. YT top-5 — strict + await tryYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, true, true), ...baseArgs]); + } + if (!hasAudio()) { + // 3. YTM top-5 — no channel check + await tryYtmSearch(5, mf(null, true)); + } + if (!hasAudio()) { + // 4. YT top-5 — no channel check await tryYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, true), ...baseArgs]); } if (!hasAudio()) { - // 3. YTM top-5 — title only + // 5. YTM top-5 — title only log(jobId, '⚠️ Letzter gefilterter Versuch…'); await tryYtmSearch(5, mf(null, false)); } if (!hasAudio()) { - // 4. YT top-5 — title only + // 6. YT top-5 — title only await tryYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, false), ...baseArgs]); } if (!hasAudio()) { - // 5. YTM — first video result, no filter + // 7. YTM — first video result, no filter log(jobId, '⚠️ Letzter Versuch: keine Filter…'); await tryYtmSearch(3, []); } if (!hasAudio()) { - // 6. YT — no filter + // 8. YT — no filter await tryYtdlp([`ytsearch1:${query}`, ...PI_UNFILTERED, ...baseArgs]); } }