From 016a23117444e66d77b7f376ade512850a41242c Mon Sep 17 00:00:00 2001 From: Honey Date: Sat, 2 May 2026 11:26:04 +0200 Subject: [PATCH] fix(backend): stricter duration filter + variant title filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/novasonic-server.js | 88 +++++++++++++++++++++++++++++-------- 1 file changed, 69 insertions(+), 19 deletions(-) diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index 74386fb..0e836f6 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -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()) {