Compare commits

...
3 Commits
Author SHA1 Message Date
HoneyandClaude Sonnet 4.6 2d1c9029c5 fix: add YTM 'Songs only' filter param to search URL
Without &params=EgWKAQII..., yt-dlp gets a mixed feed of videos,
channels and playlists alongside tracks — same query that shows the
correct song first in the browser "Titel" tab returned nursery rhyme
videos and unrelated channels. This param is YTM's internal 'Songs'
filter (equivalent to clicking the Titel tab).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 15:38:35 +02:00
HoneyandClaude Sonnet 4.6 fed3df7e36 fix: normalize diacritics in playlist artist matching
normD strips accents/special chars before comparing artist first-word
so "Kløn" (iTunes) matches "Klon" (embedded tag) → "klon" === "klon"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 15:33:01 +02:00
HoneyandClaude Sonnet 4.6 971cadd8fa fix: match-filter trailing-s optional + playlist search fallback for special chars
- buildTitleKeywords: append '?' to keywords ending in 's' so regex
  "Whispers?" matches both "Whisper" (YouTube title) and "Whispers"
  (iTunes title) — fixes NoAki Whispers downloading wrong track
- _createNdPlaylist: if artist+title search returns no title match,
  retry with title-only query so tracks with special chars in artist
  name (e.g. "Kløn") are still found in Navidrome

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 14:57:51 +02:00
2 changed files with 25 additions and 10 deletions

No files matched your search

+7 -2
View File
@@ -83,7 +83,9 @@ function buildTitleKeywords(title) {
.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] || ''; const main = words.sort((a, b) => b.length - a.length)[0] || '';
const qualifier = lower.includes('mix') ? MIX_QUALIFIERS.find(q => lower.includes(q)) : null; const qualifier = lower.includes('mix') ? MIX_QUALIFIERS.find(q => lower.includes(q)) : null;
const result = main ? [main] : []; // Make trailing 's' optional so "Whispers?" matches both "Whisper" and "Whispers" in yt-dlp regex
const toPattern = w => (w.length >= 4 && w.endsWith('s')) ? w + '?' : w;
const result = main ? [toPattern(main)] : [];
if (qualifier && main.toLowerCase() !== qualifier) result.push(qualifier); if (qualifier && main.toLowerCase() !== qualifier) result.push(qualifier);
return result; return result;
} }
@@ -272,7 +274,10 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t
); );
}); });
const ytmUrl = `https://music.youtube.com/search?q=${encodeURIComponent(query)}`; // &params= is YTM's "Songs only" filter (same as clicking "Titel" tab in the browser).
// Without it yt-dlp gets a mixed feed of videos, channels, and playlists as well.
const YTM_SONGS_PARAM = 'EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D';
const ytmUrl = `https://music.youtube.com/search?q=${encodeURIComponent(query)}&params=${YTM_SONGS_PARAM}`;
// Snapshot files already in dir before we start — so hasAudio() only detects NEW files // Snapshot files already in dir before we start — so hasAudio() only detects NEW files
// from the current download attempt, not pre-existing files from parallel jobs. // from the current download attempt, not pre-existing files from parallel jobs.
let snapBefore = new Set(fs.readdirSync(dir)); let snapBefore = new Set(fs.readdirSync(dir));
+18 -8
View File
@@ -371,7 +371,9 @@ async function _createNdPlaylist(tracks, name) {
toast('🎵 Erstelle Navidrome-Playlist…'); toast('🎵 Erstelle Navidrome-Playlist…');
const songIds = []; const songIds = [];
const missing = []; const missing = [];
const norm = s => s?.toLowerCase().trim() || ''; // Normalize: lowercase, trim, strip diacritics (ø→o, ä→a, etc.) for fuzzy matching
const norm = s => s?.toLowerCase().trim() || '';
const normD = s => norm(s).normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^\w\s]/g, '');
for (const t of tracks) { for (const t of tracks) {
if (!t.trackName) continue; if (!t.trackName) continue;
const missingLabel = t.artistName ? `${t.artistName} – ${t.trackName}` : t.trackName; const missingLabel = t.artistName ? `${t.artistName} – ${t.trackName}` : t.trackName;
@@ -380,13 +382,21 @@ async function _createNdPlaylist(tracks, name) {
const firstArtist = t.artistName ? t.artistName.split(/[,/•]/)[0].trim() : ''; const firstArtist = t.artistName ? t.artistName.split(/[,/•]/)[0].trim() : '';
const artistQuery = firstArtist ? `${firstArtist} ${t.trackName}` : t.trackName; const artistQuery = firstArtist ? `${firstArtist} ${t.trackName}` : t.trackName;
const r = await api('search3', `&query=${encodeURIComponent(artistQuery)}&songCount=20&albumCount=0&artistCount=0`); const r = await api('search3', `&query=${encodeURIComponent(artistQuery)}&songCount=20&albumCount=0&artistCount=0`);
const songs = r.searchResult3?.song || []; let songs = r.searchResult3?.song || [];
// Strip trailing punctuation from first word so "Kløn," matches "Kløn" // If artist+title query returned no title match (e.g. special chars in artist like "Kløn"),
const firstWord = s => norm(s).split(/[\s,/•]+/)[0].replace(/\W+$/, ''); // retry with title-only so the track can still be found
const match = songs.find(s => if (!songs.some(s => norm(s.title) === norm(t.trackName))) {
norm(s.title) === norm(t.trackName) && try {
firstWord(s.artist) === firstWord(firstArtist || t.artistName) const r2 = await api('search3', `&query=${encodeURIComponent(t.trackName)}&songCount=20&albumCount=0&artistCount=0`);
) || songs.find(s => norm(s.title) === norm(t.trackName)); songs = r2.searchResult3?.song || [];
} catch { /* keep original songs */ }
}
// firstWordD: first word of artist, diacritics stripped — "Kløn" and "Klon" both → "klon"
const firstWordD = s => normD(s).split(/\s+/)[0];
const titleMatch = s => norm(s.title) === norm(t.trackName);
const artistMatch = s => firstWordD(s.artist) === firstWordD(firstArtist || t.artistName);
const match = songs.find(s => titleMatch(s) && artistMatch(s))
|| songs.find(s => titleMatch(s));
if (match) songIds.push(match.id); if (match) songIds.push(match.id);
else missing.push(missingLabel); else missing.push(missingLabel);
} catch { missing.push(missingLabel); } } catch { missing.push(missingLabel); }