fix: use original artist name in YTM search and add strict channel filter

- YTM URL now uses unstripped artist name (e.g. "N!GHT" instead of "NGHT") for
  better search ranking on YouTube Music
- Steps 1-2 of cascade now include a soft channel ~= filter using the artist's
  first word, preventing wrong-artist songs with matching titles from being
  downloaded (e.g. a different "Moonlight" for N!GHT)
- Steps 3+ drop the channel filter so label-hosted tracks still get found

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-03 02:25:28 +02:00
1 parent 7b92cf7444
commit 03fb45b986
1 file changed
+34 -16
+34 -16
View File
@@ -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)}&params=${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)}&params=${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]);
}
}