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:
1 parent
34afeb77ba
commit
016a231174
1 file changed
+69
-19
+69
-19
@@ -65,6 +65,27 @@ function artistFilterWord(name) {
|
|||||||
return word.length >= 3 ? word : '';
|
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.
|
// Convert multi-artist strings to " / " separator so Navidrome splits correctly.
|
||||||
// Handles: "feat." / "ft." / "featuring" and comma-separated artists from Spotify/iTunes.
|
// Handles: "feat." / "ft." / "featuring" and comma-separated artists from Spotify/iTunes.
|
||||||
function navidromeArtist(name) {
|
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 hasAudio = () => fs.readdirSync(dir).some(f => AUDIO_EXTS.some(e => f.endsWith(`.${e}`)));
|
||||||
const ytmUrl = `https://music.youtube.com/search?q=${encodeURIComponent(query)}`;
|
const ytmUrl = `https://music.youtube.com/search?q=${encodeURIComponent(query)}`;
|
||||||
|
|
||||||
const uploaderWord = artistFilterWord(mainArtist);
|
const uploaderWord = artistFilterWord(mainArtist);
|
||||||
const uploaderFilter = uploaderWord ? ` & uploader *= ${uploaderWord}` : '';
|
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) {
|
if (durationMs) {
|
||||||
const durationSec = Math.round(durationMs / 1000);
|
const durationSec = Math.round(durationMs / 1000);
|
||||||
const minDur = Math.max(30, Math.round(durationSec * 0.6));
|
const margin = Math.max(15, Math.round(durationSec * 0.15)); // ±15 % (min 15 s)
|
||||||
const maxDur = Math.round(durationSec * 1.5);
|
const dur = { min: Math.max(30, durationSec - margin), max: durationSec + margin };
|
||||||
const durArtistFilter = ['--match-filter', `duration > ${minDur} & duration < ${maxDur} & !is_live${uploaderFilter}`];
|
|
||||||
const durOnlyFilter = ['--match-filter', `duration > ${minDur} & duration < ${maxDur} & !is_live`];
|
|
||||||
|
|
||||||
log(jobId, `🔍 Suche YT Music (~${durationSec}s): "${query}"`);
|
log(jobId, `🔍 Suche YT Music (~${durationSec}s ±${margin}s): "${query}"`);
|
||||||
await spawnYtdlp([ytmUrl, '--playlist-items', '1', ...durArtistFilter, ...baseArgs]);
|
// 1. YTM — Dauer + Artist + Varianten-Filter (streng)
|
||||||
|
await spawnYtdlp([ytmUrl, '--playlist-items', '1', ...mf(dur, true, true), ...baseArgs]);
|
||||||
|
|
||||||
if (!hasAudio()) {
|
if (!hasAudio()) {
|
||||||
log(jobId, '⚠️ Kein YT-Music-Treffer (Dauer+Artist) — Fallback zu YouTube…');
|
// 2. YT — Dauer + Artist + Varianten-Filter
|
||||||
await spawnYtdlp([`ytsearch1:${query}`, ...durArtistFilter, ...baseArgs]);
|
log(jobId, '⚠️ Kein YTM-Treffer — Fallback zu YouTube…');
|
||||||
|
await spawnYtdlp([`ytsearch1:${query}`, ...mf(dur, true, true), ...baseArgs]);
|
||||||
}
|
}
|
||||||
if (!hasAudio()) {
|
if (!hasAudio()) {
|
||||||
log(jobId, '⚠️ Kein Treffer mit Artist-Filter — Suche nur nach Dauer…');
|
// 3. YTM — Dauer + Varianten-Filter (kein Artist)
|
||||||
await spawnYtdlp([ytmUrl, '--playlist-items', '1', ...durOnlyFilter, ...baseArgs]);
|
log(jobId, '⚠️ Kein Treffer mit Artist-Filter — ohne Artist…');
|
||||||
|
await spawnYtdlp([ytmUrl, '--playlist-items', '1', ...mf(dur, false, true), ...baseArgs]);
|
||||||
}
|
}
|
||||||
if (!hasAudio()) {
|
if (!hasAudio()) {
|
||||||
await spawnYtdlp([`ytsearch1:${query}`, ...durOnlyFilter, ...baseArgs]);
|
// 4. YT — Dauer + Varianten-Filter
|
||||||
|
await spawnYtdlp([`ytsearch1:${query}`, ...mf(dur, false, true), ...baseArgs]);
|
||||||
}
|
}
|
||||||
if (!hasAudio()) {
|
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]);
|
await spawnYtdlp([ytmUrl, '--playlist-items', '1', ...baseArgs]);
|
||||||
}
|
}
|
||||||
if (!hasAudio()) {
|
if (!hasAudio()) {
|
||||||
@@ -260,14 +303,21 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log(jobId, `🔍 Suche YT Music: "${query}"`);
|
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()) {
|
if (!hasAudio()) {
|
||||||
log(jobId, '⚠️ Kein YT-Music-Treffer — Fallback zu YouTube…');
|
await spawnYtdlp([`ytsearch1:${query}`, ...mf(null, true, true), ...baseArgs]);
|
||||||
await spawnYtdlp([`ytsearch1:${query}`, ...(uploaderFilter ? ['--match-filter', `!is_live${uploaderFilter}`] : []), ...baseArgs]);
|
|
||||||
}
|
}
|
||||||
if (!hasAudio() && uploaderFilter) {
|
if (!hasAudio()) {
|
||||||
log(jobId, '⚠️ Kein Treffer mit Artist-Filter — Suche ohne…');
|
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]);
|
await spawnYtdlp([ytmUrl, '--playlist-items', '1', ...baseArgs]);
|
||||||
}
|
}
|
||||||
if (!hasAudio()) {
|
if (!hasAudio()) {
|
||||||
|
|||||||
Reference in new issue
Block a user