fix: strip parens from keywords, top-10 search, clean search query

- buildTitleKeywords: strip parenthetical tags before picking words so
  "Eleven Night (Jersey Club)" → keywords ["Eleven","Night"] instead of
  ["Eleven","Jersey"] — "Jersey" was never in the YT video title
- Cascade: add top-10 steps (3+4) with duration still active before
  dropping duration filter, so "Whispers" (position 6-10) can be found
  without falling through to the unfiltered last resort
- search query: strip non-ASCII chars from artist name so "☆NoAki☆"
  becomes "NoAki" in the query → cleaner YTM search ranking

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-02 16:49:53 +02:00
1 parent 51d2ea257f
commit afcbf81547
1 file changed
+25 -10
+25 -10
View File
@@ -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,9 +256,10 @@ 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_FILTERED_10 = ['--playlist-items', '1-10', '--max-downloads', '1'];
const PI_UNFILTERED = ['--playlist-items', '1'];
const spawnYtdlp = (args) => new Promise((resolve, reject) => {
@@ -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]);
}