fix: case-insensitive title filter + scan before playlist creation

backend: replace title *= (case-sensitive) with title ~= (?i) regex so
"MEMORIES" and "Echoes of Eternity" match regardless of YT title casing.
Refactor buildTitleKeyword → buildTitleKeywords returning array.

frontend: trigger Navidrome startScan after download completes and wait
for scan to finish before running _createNdPlaylist, so newly downloaded
tracks are indexed and searchable when the playlist is built.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-02 13:40:20 +02:00
1 parent 42658b3cbf
commit 7a0a7ca455
2 files changed
+26 -12

No files matched your search

+14 -12
View File
@@ -67,24 +67,25 @@ function artistFilterWord(name) {
return best || '';
}
// Extract the most distinctive word from a track title to use as a title relevance filter.
// Prevents fallback steps from downloading completely unrelated tracks.
// Also preserves version qualifiers ("radio mix", "extended mix", "club mix", "vip mix") so
// "Eclipse - Radio Mix" filters for "eclipse" AND "radio" to avoid downloading the plain version.
function buildTitleKeyword(title) {
// Extract distinctive words from a track title for case-insensitive yt-dlp title ~= (?i) filters.
// Returns an array: [mainWord, optionalMixQualifier].
// "Memories" → ["Memories"] (matches "MEMORIES", "memories", etc.)
// "Eclipse - Radio Mix" → ["Eclipse", "radio"]
function buildTitleKeywords(title) {
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 MIX_QUALIFIERS = ['radio', 'extended', 'club', 'vip', 'original', 'edit'];
const lower = (title || '').toLowerCase();
const words = lower
// Keep original case so the word looks right in logs, case-insensitivity is handled by (?i) flag
const words = (title || '')
.replace(/[^\w\s]/g, ' ')
.split(/\s+/)
.filter(w => w.length >= 3 && !VARIANT_WORDS.has(w) && !STOPWORDS.has(w));
.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] || '';
// If the title has a mix qualifier AND "mix", include the qualifier word too
const qualifier = lower.includes('mix') ? MIX_QUALIFIERS.find(q => lower.includes(q)) : null;
if (qualifier && qualifier !== main) return `${main} & title *= ${qualifier}`;
return main;
const result = main ? [main] : [];
if (qualifier && main.toLowerCase() !== qualifier) result.push(qualifier);
return result;
}
// Build yt-dlp --match-filter conditions to reject variant versions (speed up, slowed, remix…)
@@ -283,10 +284,11 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t
const uploaderWord = artistFilterWord(mainArtist);
const variantFilter = buildVariantFilter(title);
const titleKeyword = buildTitleKeyword(title);
const titleKws = buildTitleKeywords(title);
const uploaderPart = uploaderWord ? ` & artist *= ${uploaderWord}` : '';
const variantPart = variantFilter ? ` & ${variantFilter}` : '';
const titleKwPart = titleKeyword ? ` & title *= ${titleKeyword}` : '';
// ~= uses Python re.search; (?i) inline flag = case-insensitive substring match
const titleKwPart = titleKws.length ? titleKws.map(k => ` & title ~= (?i)${k}`).join('') : '';
// Build a --match-filter arg from active parts (duration, artist, variant, title keyword).
// !is_live is always included. titleKw always included to avoid totally unrelated downloads.
+12
View File
@@ -344,6 +344,18 @@ async function _pollAndCreatePlaylist(jobId, tracks, playlistName) {
if (!job || job.status === 'done' || job.status === 'error') break;
}
if (S.jobs[jobId]?.status !== 'done') return;
// Trigger Navidrome library scan so newly downloaded files are indexed before searching
try {
await api('startScan', '');
toast('🔍 Navidrome scannt Bibliothek…');
for (let i = 0; i < 90; i++) {
await new Promise(r => setTimeout(r, 2000));
const st = await api('getScanStatus', '');
if (!st?.scanStatus?.scanning) break;
}
} catch { /* scan API unavailable — proceed anyway */ }
await _createNdPlaylist(tracks, playlistName);
}