fix: use top-2 title keywords for precise match-filter

Single-word filter was too loose: "Echoes of Eternity" produced only
"Eternity" which matched unrelated tracks. Now the top 2 longest words
are used so the filter becomes e.g.
  title ~= (?i)Eternity & title ~= (?i)Echoes?
  title ~= (?i)Amethyst & title ~= (?i)Flowers?
Both words must appear — drastically reduces false matches.
Trailing-s stays optional (Whispers? matches Whisper/Whispers).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-02 16:10:05 +02:00
1 parent 5e7e700d6a
commit 51d2ea257f
1 file changed
+14 -9
+14 -9
View File
@@ -68,25 +68,30 @@ function artistFilterWord(name) {
} }
// Extract distinctive words from a track title for case-insensitive yt-dlp title ~= (?i) filters. // Extract distinctive words from a track title for case-insensitive yt-dlp title ~= (?i) filters.
// Returns an array: [mainWord, optionalMixQualifier]. // Returns up to 2 of the most distinctive words so the filter is precise:
// "Memories" → ["Memories"] (matches "MEMORIES", "memories", etc.) // "Echoes of Eternity" → ["Eternity", "Echoes"] → title ~= (?i)Eternity & title ~= (?i)Echoes
// "Eclipse - Radio Mix" → ["Eclipse", "radio"] // "Amethyst Flowers" → ["Amethyst", "Flowers?"] → title ~= (?i)Amethyst & title ~= (?i)Flowers?
// "Whispers" → ["Whispers?"] → title ~= (?i)Whispers?
// "Eclipse - Radio Mix"→ ["Eclipse", "Radio"] → title ~= (?i)Eclipse & title ~= (?i)Radio
// Trailing 's' is made optional (Whispers? matches "Whisper" or "Whispers") to handle
// cases where the YouTube video title drops the plural form.
function buildTitleKeywords(title) { function buildTitleKeywords(title) {
const VARIANT_WORDS = new Set(['sped','speedup','slowed','reverb','nightcore','karaoke','parody','remix','cover','acoustic','instrumental']); const VARIANT_WORDS = new Set(['sped','speedup','slowed','reverb','nightcore','karaoke','parody','remix','cover','acoustic','instrumental']);
const STOPWORDS = new Set(['the','a','an','and','or','of','in','on','at','to','for','feat','ft','with']); 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 MIX_QUALIFIERS = ['radio', 'extended', 'club', 'vip', 'original', 'edit'];
const lower = (title || '').toLowerCase(); const lower = (title || '').toLowerCase();
// Keep original case so the word looks right in logs, case-insensitivity is handled by (?i) flag
const words = (title || '') const words = (title || '')
.replace(/[^\w\s]/g, ' ') .replace(/[^\w\s]/g, ' ')
.split(/\s+/) .split(/\s+/)
.filter(w => w.length >= 3 && !VARIANT_WORDS.has(w.toLowerCase()) && !STOPWORDS.has(w.toLowerCase())); .filter(w => w.length >= 3 && !VARIANT_WORDS.has(w.toLowerCase()) && !STOPWORDS.has(w.toLowerCase()));
const main = words.sort((a, b) => b.length - a.length)[0] || ''; // Take top 2 by length — two words together are far more precise than one alone
const qualifier = lower.includes('mix') ? MIX_QUALIFIERS.find(q => lower.includes(q)) : null; const top2 = words.sort((a, b) => b.length - a.length).slice(0, 2);
// Make trailing 's' optional so "Whispers?" matches both "Whisper" and "Whispers" in yt-dlp regex // Make trailing 's' optional so "Whispers?" matches "Whisper" or "Whispers"
const toPattern = w => (w.length >= 4 && w.endsWith('s')) ? w + '?' : w; const toPattern = w => (w.length >= 4 && w.endsWith('s')) ? w + '?' : w;
const result = main ? [toPattern(main)] : []; const result = top2.map(toPattern);
if (qualifier && main.toLowerCase() !== qualifier) result.push(qualifier); // Add mix qualifier only if neither top word already covers it
const qualifier = lower.includes('mix') ? MIX_QUALIFIERS.find(q => lower.includes(q)) : null;
if (qualifier && !top2.some(w => w.toLowerCase() === qualifier)) result.push(qualifier);
return result; return result;
} }