From 7bec106350137d0bcda50d2b91d08b10907f8e72 Mon Sep 17 00:00:00 2001 From: Honey Date: Sun, 17 May 2026 20:39:07 +0200 Subject: [PATCH] fix: fetch YTM URLs once and reuse across cascade steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each tryYtmSearch() call made a fresh HTTP request, so YouTube could return different result orderings between steps. The correct video (right title, wrong channel name) appeared in step 1 but was gone by step 3 — a worse video with matching keywords slipped through instead. Now fetch 10 YTM URLs once upfront and filter the same list with progressively relaxed rules. Also saves 2-4 extra YTM requests per download. Co-Authored-By: Claude Sonnet 4.6 --- backend/lib/ytdlp.js | 47 ++++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/backend/lib/ytdlp.js b/backend/lib/ytdlp.js index 5d01c32..5322dff 100644 --- a/backend/lib/ytdlp.js +++ b/backend/lib/ytdlp.js @@ -145,15 +145,6 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t proc.on('close', () => resolve(channelExpanded ? [] : urls)); }); - // Search YTM for video URLs (pre-filtered), then pass them all at once to yt-dlp. - // --max-downloads 1 makes yt-dlp stop after the first video that passes mfArgs. - // Filtered/skipped videos do NOT count toward --max-downloads, so the cascade works. - const tryYtmSearch = async (maxItems, mfArgs) => { - const urls = await getYtmVideoUrls(maxItems); - if (!urls.length) return; - await spawnYtdlp([...urls, '--max-downloads', '1', ...mfArgs, ...baseArgs]); - }; - const variantFilter = buildVariantFilter(title); const titleKws = buildTitleKeywords(title); const variantPart = variantFilter ? ` & ${variantFilter}` : ''; @@ -177,6 +168,16 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t const PI_FILTERED_10 = ['--playlist-items', '1-10', '--max-downloads', '1']; const PI_UNFILTERED = ['--playlist-items', '1']; + // Fetch YTM URLs once upfront so every cascade step filters the SAME result set. + // Re-fetching produced different orderings, causing the correct video (found in step 1 + // but failing the channel filter) to disappear by step 3 and a wrong one to slip through. + const ytmUrls = await getYtmVideoUrls(10); + const tryYtm = async (n, mfArgs) => { + const urls = n ? ytmUrls.slice(0, n) : ytmUrls; + if (!urls.length) return; + await spawnYtdlp([...urls, '--max-downloads', '1', ...mfArgs, ...baseArgs]); + }; + if (durationMs) { const durationSec = Math.round(durationMs / 1000); const margin = Math.max(15, Math.round(durationSec * 0.15)); @@ -184,34 +185,34 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t log(jobId, `🔍 Suche YT Music (~${durationSec}s ±${margin}s): "${query}"`); // 1. YTM top-5 — strict: duration + title + variant + channel check - await tryYtmSearch(5, mf(dur, true, true)); + await tryYtm(5, mf(dur, true, true)); if (!hasAudio()) { // 2. YT top-5 — strict: same filters including channel check log(jobId, '⚠️ Kein YTM-Treffer — Fallback zu YouTube…'); await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(dur, true, true), ...baseArgs]); } if (!hasAudio()) { - // 3. YTM top-10 — no channel check (label channels, etc.) + // 3. YTM all-10 — no channel check (label channels, etc.) log(jobId, '⚠️ Erweitere Suche auf top 10 mit Dauerbeschränkung…'); - await tryYtmSearch(10, mf(dur, true)); + await tryYtm(0, mf(dur, true)); } if (!hasAudio()) { // 4. YT top-10 — no channel check await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(dur, true), ...baseArgs]); } if (!hasAudio()) { - // 5. YTM top-5 — no duration check + // 5. YTM all — no duration check log(jobId, '⚠️ Kein Treffer im Dauerbereich — suche ohne Dauerbeschränkung…'); - await tryYtmSearch(5, mf(null, true)); + await tryYtm(0, mf(null, true)); } if (!hasAudio()) { // 6. YT top-5 — no duration check await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, true), ...baseArgs]); } if (!hasAudio()) { - // 7. YTM top-5 — title only (no variant filter) + // 7. YTM all — title only (no variant filter) log(jobId, '⚠️ Letzter gefilterter Versuch…'); - await tryYtmSearch(5, mf(null, false)); + await tryYtm(0, mf(null, false)); } if (!hasAudio()) { // 8. YT top-5 — title only @@ -220,7 +221,7 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t if (!hasAudio()) { // 9. YTM — no filter at all, take first video result log(jobId, '⚠️ Letzter Versuch: keine Filter…'); - await tryYtmSearch(3, []); + await tryYtm(3, []); } if (!hasAudio()) { // 10. YT — no filter @@ -229,23 +230,23 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t } else { log(jobId, `🔍 Suche YT Music: "${query}"`); // 1. YTM top-5 — strict: title + variant + channel check - await tryYtmSearch(5, mf(null, true, true)); + await tryYtm(5, mf(null, true, true)); if (!hasAudio()) { // 2. YT top-5 — strict await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, true, true), ...baseArgs]); } if (!hasAudio()) { - // 3. YTM top-5 — no channel check - await tryYtmSearch(5, mf(null, true)); + // 3. YTM all-10 — no channel check (catches label/distributor channels) + await tryYtm(0, mf(null, true)); } if (!hasAudio()) { // 4. YT top-5 — no channel check await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, true), ...baseArgs]); } if (!hasAudio()) { - // 5. YTM top-5 — title only + // 5. YTM all — title only log(jobId, '⚠️ Letzter gefilterter Versuch…'); - await tryYtmSearch(5, mf(null, false)); + await tryYtm(0, mf(null, false)); } if (!hasAudio()) { // 6. YT top-5 — title only @@ -254,7 +255,7 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t if (!hasAudio()) { // 7. YTM — first video result, no filter log(jobId, '⚠️ Letzter Versuch: keine Filter…'); - await tryYtmSearch(3, []); + await tryYtm(3, []); } if (!hasAudio()) { // 8. YT — no filter