fix(backend): stricter duration filter + variant title filter

- Duration: ±15% (min 15s) instead of -40%/+50%
- Variant filter: reject sped/speedup/slowed/reverb/nightcore/karaoke/
  parody/remix/cover/acoustic/instrumental unless original title has keyword
- Cascade: YTM+YT tried at each level (dur+artist+variant → no artist → no dur → no filter)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-02 11:26:04 +02:00
1 parent 34afeb77ba
commit 016a231174
1 file changed
+69 -19
+69 -19
View File
@@ -65,6 +65,27 @@ function artistFilterWord(name) {
return word.length >= 3 ? word : '';
}
// Build yt-dlp --match-filter conditions to reject variant versions (speed up, slowed, remix…)
// unless the original track title already contains those keywords.
function buildVariantFilter(title) {
const t = (title || '').toLowerCase();
const RULES = [
{ key: 'sped', expr: 'title !*= sped' }, // "sped up"
{ key: 'speedup', expr: 'title !*= speedup' }, // "speedup"
{ key: 'slowed', expr: 'title !*= slowed' },
{ key: 'reverb', expr: 'title !*= reverb' },
{ key: 'nightcore', expr: 'title !*= nightcore' },
{ key: 'karaoke', expr: 'title !*= karaoke' },
{ key: 'parody', expr: 'title !*= parody' },
{ key: 'remix', expr: 'title !*= remix' },
{ key: 'cover', expr: 'title !*= cover' },
{ key: 'acoustic', expr: 'title !*= acoustic' },
{ key: 'instrumental', expr: 'title !*= instrumental' },
];
const parts = RULES.filter(r => !t.includes(r.key)).map(r => r.expr);
return parts.length ? parts.join(' & ') : '';
}
// Convert multi-artist strings to " / " separator so Navidrome splits correctly.
// Handles: "feat." / "ft." / "featuring" and comma-separated artists from Spotify/iTunes.
function navidromeArtist(name) {
@@ -227,32 +248,54 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t
const hasAudio = () => fs.readdirSync(dir).some(f => AUDIO_EXTS.some(e => f.endsWith(`.${e}`)));
const ytmUrl = `https://music.youtube.com/search?q=${encodeURIComponent(query)}`;
const uploaderWord = artistFilterWord(mainArtist);
const uploaderFilter = uploaderWord ? ` & uploader *= ${uploaderWord}` : '';
const uploaderWord = artistFilterWord(mainArtist);
const variantFilter = buildVariantFilter(title);
const uploaderPart = uploaderWord ? ` & uploader *= ${uploaderWord}` : '';
const variantPart = variantFilter ? ` & ${variantFilter}` : '';
// Build a --match-filter arg from active parts (duration, artist, variant).
// !is_live is always included.
const mf = (dur, artist, variant) => {
const durPart = dur ? `duration > ${dur.min} & duration < ${dur.max} & ` : '';
return ['--match-filter',
`${durPart}!is_live${artist ? uploaderPart : ''}${variant ? variantPart : ''}`];
};
if (durationMs) {
const durationSec = Math.round(durationMs / 1000);
const minDur = Math.max(30, Math.round(durationSec * 0.6));
const maxDur = Math.round(durationSec * 1.5);
const durArtistFilter = ['--match-filter', `duration > ${minDur} & duration < ${maxDur} & !is_live${uploaderFilter}`];
const durOnlyFilter = ['--match-filter', `duration > ${minDur} & duration < ${maxDur} & !is_live`];
const margin = Math.max(15, Math.round(durationSec * 0.15)); // ±15 % (min 15 s)
const dur = { min: Math.max(30, durationSec - margin), max: durationSec + margin };
log(jobId, `🔍 Suche YT Music (~${durationSec}s): "${query}"`);
await spawnYtdlp([ytmUrl, '--playlist-items', '1', ...durArtistFilter, ...baseArgs]);
log(jobId, `🔍 Suche YT Music (~${durationSec}s ±${margin}s): "${query}"`);
// 1. YTM — Dauer + Artist + Varianten-Filter (streng)
await spawnYtdlp([ytmUrl, '--playlist-items', '1', ...mf(dur, true, true), ...baseArgs]);
if (!hasAudio()) {
log(jobId, '⚠️ Kein YT-Music-Treffer (Dauer+Artist) — Fallback zu YouTube…');
await spawnYtdlp([`ytsearch1:${query}`, ...durArtistFilter, ...baseArgs]);
// 2. YT — Dauer + Artist + Varianten-Filter
log(jobId, '⚠️ Kein YTM-Treffer — Fallback zu YouTube…');
await spawnYtdlp([`ytsearch1:${query}`, ...mf(dur, true, true), ...baseArgs]);
}
if (!hasAudio()) {
log(jobId, '⚠️ Kein Treffer mit Artist-Filter — Suche nur nach Dauer…');
await spawnYtdlp([ytmUrl, '--playlist-items', '1', ...durOnlyFilter, ...baseArgs]);
// 3. YTM — Dauer + Varianten-Filter (kein Artist)
log(jobId, '⚠️ Kein Treffer mit Artist-Filter — ohne Artist…');
await spawnYtdlp([ytmUrl, '--playlist-items', '1', ...mf(dur, false, true), ...baseArgs]);
}
if (!hasAudio()) {
await spawnYtdlp([`ytsearch1:${query}`, ...durOnlyFilter, ...baseArgs]);
// 4. YT — Dauer + Varianten-Filter
await spawnYtdlp([`ytsearch1:${query}`, ...mf(dur, false, true), ...baseArgs]);
}
if (!hasAudio()) {
log(jobId, '⚠️ Kein Treffer im Dauerbereich — Suche ohne Filter…');
// 5. YTM — nur Varianten-Filter (kein Dauer-Check)
log(jobId, '⚠️ Kein Treffer im Dauerbereich — suche ohne Dauerbeschränkung…');
await spawnYtdlp([ytmUrl, '--playlist-items', '1', ...mf(null, false, true), ...baseArgs]);
}
if (!hasAudio()) {
// 6. YT — nur Varianten-Filter
await spawnYtdlp([`ytsearch1:${query}`, ...mf(null, false, true), ...baseArgs]);
}
if (!hasAudio()) {
// 7+8. Letzter Ausweg: komplett ungefiltert
log(jobId, '⚠️ Letzter Versuch: keine Filter…');
await spawnYtdlp([ytmUrl, '--playlist-items', '1', ...baseArgs]);
}
if (!hasAudio()) {
@@ -260,14 +303,21 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t
}
} else {
log(jobId, `🔍 Suche YT Music: "${query}"`);
await spawnYtdlp([ytmUrl, '--playlist-items', '1', ...(uploaderFilter ? ['--match-filter', `!is_live${uploaderFilter}`] : []), ...baseArgs]);
// Ohne Dauer: Artist + Varianten → ohne Artist → ohne alles
await spawnYtdlp([ytmUrl, '--playlist-items', '1', ...mf(null, true, true), ...baseArgs]);
if (!hasAudio()) {
log(jobId, '⚠️ Kein YT-Music-Treffer — Fallback zu YouTube…');
await spawnYtdlp([`ytsearch1:${query}`, ...(uploaderFilter ? ['--match-filter', `!is_live${uploaderFilter}`] : []), ...baseArgs]);
await spawnYtdlp([`ytsearch1:${query}`, ...mf(null, true, true), ...baseArgs]);
}
if (!hasAudio() && uploaderFilter) {
log(jobId, '⚠️ Kein Treffer mit Artist-Filter — Suche ohne…');
if (!hasAudio()) {
log(jobId, '⚠️ Kein Treffer mit Artist — ohne Artist-Filter…');
await spawnYtdlp([ytmUrl, '--playlist-items', '1', ...mf(null, false, true), ...baseArgs]);
}
if (!hasAudio()) {
await spawnYtdlp([`ytsearch1:${query}`, ...mf(null, false, true), ...baseArgs]);
}
if (!hasAudio()) {
log(jobId, '⚠️ Letzter Versuch: keine Filter…');
await spawnYtdlp([ytmUrl, '--playlist-items', '1', ...baseArgs]);
}
if (!hasAudio()) {