diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index c6505ff..fa19612 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -80,7 +80,10 @@ function buildTitleKeywords(title) { 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 lower = (title || '').toLowerCase(); - const words = (title || '') + // Strip parenthetical genre/style tags like "(Jersey Club)", "(feat. X)" before picking keywords + // so "Eleven Night (Jersey Club)" → keywords from "Eleven Night", not "Jersey" + const strippedTitle = (title || '').replace(/\([^)]*\)/g, '').trim(); + const words = strippedTitle .replace(/[^\w\s]/g, ' ') .split(/\s+/) .filter(w => w.length >= 3 && !VARIANT_WORDS.has(w.toLowerCase()) && !STOPWORDS.has(w.toLowerCase())); @@ -239,7 +242,9 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t } const outputTemplate = path.join(dir, `${trackPrefix}${safeTitle}.%(ext)s`); - const query = `${mainArtist} - ${title}`; + // Strip non-ASCII chars (e.g. ☆) from artist name for cleaner search ranking + const searchArtist = mainArtist.replace(/[^\x00-\x7F]/g, '').trim(); + const query = `${searchArtist} - ${title}`; // Step 1: yt-dlp — audio only, no thumbnail (we embed iTunes artwork ourselves) const baseArgs = [ @@ -251,10 +256,11 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t '--no-warnings', '--newline', ]; - // Filtered searches scan top 5 results and stop at first match. + // Filtered searches: top 5 (fast) and top 10 (wider net when top 5 misses). // Unfiltered fallback only needs item 1 (no filter to skip past). - const PI_FILTERED = ['--playlist-items', '1-5', '--max-downloads', '1']; - const PI_UNFILTERED = ['--playlist-items', '1']; + const PI_FILTERED = ['--playlist-items', '1-5', '--max-downloads', '1']; + const PI_FILTERED_10 = ['--playlist-items', '1-10', '--max-downloads', '1']; + const PI_UNFILTERED = ['--playlist-items', '1']; const spawnYtdlp = (args) => new Promise((resolve, reject) => { const proc = spawn(CONFIG.ytdlpBin, args, { env: { ...process.env, PYTHONUNBUFFERED: '1' } }); @@ -313,33 +319,42 @@ 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 — Dauer + Titel + Varianten-Filter + // 1. YTM top-5 — Dauer + Titel + Varianten-Filter await tryYtdlp([ytmUrl, ...PI_FILTERED, ...mf(dur, true), ...baseArgs]); if (!hasAudio()) { - // 2. YT — Dauer + Titel + Varianten-Filter + // 2. YT top-5 — Dauer + Titel + Varianten-Filter log(jobId, '⚠️ Kein YTM-Treffer — Fallback zu YouTube…'); await tryYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(dur, true), ...baseArgs]); } if (!hasAudio()) { - // 3. YTM — Titel + Varianten (kein Dauer-Check) + // 3. YTM top-10 — Dauer + Titel (correcte Version evtl. außerhalb top-5) + log(jobId, '⚠️ Erweitere Suche auf top 10 mit Dauerbeschränkung…'); + await tryYtdlp([ytmUrl, ...PI_FILTERED_10, ...mf(dur, true), ...baseArgs]); + } + if (!hasAudio()) { + // 4. YT top-10 — Dauer + Titel + await tryYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(dur, true), ...baseArgs]); + } + if (!hasAudio()) { + // 5. YTM top-5 — Titel + Varianten (kein Dauer-Check) log(jobId, '⚠️ Kein Treffer im Dauerbereich — suche ohne Dauerbeschränkung…'); await tryYtdlp([ytmUrl, ...PI_FILTERED, ...mf(null, true), ...baseArgs]); } if (!hasAudio()) { - // 4. YT — Titel + Varianten + // 6. YT top-5 — Titel + Varianten await tryYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, true), ...baseArgs]); } if (!hasAudio()) { - // 5. YTM — nur Titel (kein Varianten-Filter) + // 7. YTM top-5 — nur Titel (kein Varianten-Filter) log(jobId, '⚠️ Letzter gefilterter Versuch…'); await tryYtdlp([ytmUrl, ...PI_FILTERED, ...mf(null, false), ...baseArgs]); } if (!hasAudio()) { - // 6. YT — nur Titel + // 8. YT top-5 — nur Titel await tryYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, false), ...baseArgs]); } if (!hasAudio()) { - // 7+8. Letzter Ausweg: komplett ungefiltert + // 9+10. Letzter Ausweg: komplett ungefiltert log(jobId, '⚠️ Letzter Versuch: keine Filter…'); await tryYtdlp([ytmUrl, ...PI_UNFILTERED, ...baseArgs]); }