feat: filter yt-dlp results by song duration to avoid wrong downloads

When iTunes/Spotify provides a track duration, search 5 YouTube results
and keep only those within 60–150% of the expected length (filters remixes,
prayer videos, etc.). Falls back to ytsearch1: if no match is found.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-04-28 18:48:00 +02:00
1 parent dccf8a5dab
commit fb02d3faf4
2 files changed
+46 -19

No files matched your search

+41 -15
View File
@@ -156,12 +156,13 @@ async function lookupItunesTrackMeta(artist, title) {
discTotal: match.discCount || undefined, discTotal: match.discCount || undefined,
genre: match.primaryGenreName || undefined, genre: match.primaryGenreName || undefined,
artworkUrl: (match.artworkUrl100 || '').replace('100x100bb', '1000x1000bb'), artworkUrl: (match.artworkUrl100 || '').replace('100x100bb', '1000x1000bb'),
durationMs: match.trackTimeMillis || undefined,
}; };
} catch { return {}; } } catch { return {}; }
} }
// Download a single track: yt-dlp audio + ffmpeg clean metadata + iTunes cover embedded // Download a single track: yt-dlp audio + ffmpeg clean metadata + iTunes cover embedded
async function downloadTrack({ jobId, artist, albumArtist, title, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, outputDir }) { async function downloadTrack({ jobId, artist, albumArtist, title, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, outputDir, durationMs }) {
const mainArtist = albumArtist || artist; const mainArtist = albumArtist || artist;
const safeArtist = sanitize(mainArtist.split(',')[0].trim()); const safeArtist = sanitize(mainArtist.split(',')[0].trim());
const safeAlbum = sanitize(album || 'Singles'); const safeAlbum = sanitize(album || 'Singles');
@@ -179,21 +180,20 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t
const outputTemplate = path.join(dir, `${trackPrefix}${safeTitle}.%(ext)s`); const outputTemplate = path.join(dir, `${trackPrefix}${safeTitle}.%(ext)s`);
const query = `${mainArtist} - ${title} official audio`; const query = `${mainArtist} - ${title} official audio`;
log(jobId, `🔍 Suche: "${query}"`);
// Step 1: yt-dlp — audio only, no thumbnail (we embed iTunes artwork ourselves) // Step 1: yt-dlp — audio only, no thumbnail (we embed iTunes artwork ourselves)
await new Promise((resolve, reject) => { const baseArgs = [
const args = [ '--extract-audio',
`ytsearch1:${query}`, '--audio-format', CONFIG.audioFmt,
'--extract-audio', '--audio-quality', CONFIG.audioQuality,
'--audio-format', CONFIG.audioFmt, '-o', outputTemplate,
'--audio-quality', CONFIG.audioQuality, '--no-playlist',
'-o', outputTemplate, '--playlist-items', '1',
'--no-playlist', '--no-warnings',
'--playlist-items', '1', '--newline',
'--no-warnings', ];
'--newline',
]; const spawnYtdlp = (args) => new Promise((resolve, reject) => {
const proc = spawn(CONFIG.ytdlpBin, args, { env: { ...process.env, PYTHONUNBUFFERED: '1' } }); const proc = spawn(CONFIG.ytdlpBin, args, { env: { ...process.env, PYTHONUNBUFFERED: '1' } });
let stderr = '', lastPct = ''; let stderr = '', lastPct = '';
proc.stdout.on('data', d => { proc.stdout.on('data', d => {
@@ -215,6 +215,28 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t
); );
}); });
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);
log(jobId, `🔍 Suche (~${durationSec}s): "${query}"`);
await spawnYtdlp([
`ytsearch5:${query}`,
'--match-filter', `duration > ${minDur} & duration < ${maxDur} & !is_live`,
'--max-downloads', '1',
...baseArgs,
]);
// Fallback if duration filter matched nothing
const hasFile = fs.readdirSync(dir).some(f => AUDIO_EXTS.some(e => f.endsWith(`.${e}`)));
if (!hasFile) {
log(jobId, '⚠️ Kein Treffer im Dauerbereich — Suche ohne Filter…');
await spawnYtdlp([`ytsearch1:${query}`, ...baseArgs]);
}
} else {
log(jobId, `🔍 Suche: "${query}"`);
await spawnYtdlp([`ytsearch1:${query}`, ...baseArgs]);
}
// Step 2: find the audio file yt-dlp wrote (newest audio file in dir) // Step 2: find the audio file yt-dlp wrote (newest audio file in dir)
const audioFile = fs.readdirSync(dir) const audioFile = fs.readdirSync(dir)
.filter(f => AUDIO_EXTS.some(e => f.endsWith(`.${e}`))) .filter(f => AUDIO_EXTS.some(e => f.endsWith(`.${e}`)))
@@ -323,6 +345,7 @@ async function downloadAlbumTracks({ jobId, artist, albumTitle, tracks, artworkU
artworkUrl: artworkUrl || track.artworkUrl100, artworkUrl: artworkUrl || track.artworkUrl100,
explicit: track.trackExplicitness === 'explicit', explicit: track.trackExplicitness === 'explicit',
outputDir: CONFIG.musicPath, outputDir: CONFIG.musicPath,
durationMs: track.trackTimeMillis || undefined,
}); });
if (result?.skipped) skipped++; else done++; if (result?.skipped) skipped++; else done++;
updateJob(jobId, { progress: Math.round(((done + skipped) / total) * 90) }); updateJob(jobId, { progress: Math.round(((done + skipped) / total) * 90) });
@@ -480,6 +503,7 @@ function _spTrackItem(t, defaultAlbum, defaultArt, defaultAlbumArtist) {
trackNumber: d?.trackNumber || d?.track_number, trackNumber: d?.trackNumber || d?.track_number,
trackExplicitness: d?.contentRating?.label === 'EXPLICIT' || d?.isExplicit || d?.explicit ? 'explicit' : 'notExplicit', trackExplicitness: d?.contentRating?.label === 'EXPLICIT' || d?.isExplicit || d?.explicit ? 'explicit' : 'notExplicit',
previewUrl: d?.previewUrl || d?.preview_url || d?.audioPreview?.url || null, previewUrl: d?.previewUrl || d?.preview_url || d?.audioPreview?.url || null,
durationMs: d?.duration_ms || d?.durationMs || null,
}; };
} }
@@ -590,7 +614,8 @@ app.post('/api/download', async (req, res) => {
totalTracks: sp.trackCount || bodyTotalTracks || undefined, totalTracks: sp.trackCount || bodyTotalTracks || undefined,
discNum: sp.discNumber || bodyDiscNum || undefined, discNum: sp.discNumber || bodyDiscNum || undefined,
discTotal: sp.discCount || bodyDiscTotal || undefined, discTotal: sp.discCount || bodyDiscTotal || undefined,
artworkUrl: spArt, explicit: sp.trackExplicitness === 'explicit' || !!explicit }; artworkUrl: spArt, explicit: sp.trackExplicitness === 'explicit' || !!explicit,
durationMs: sp.durationMs || undefined };
if (!meta.album) { if (!meta.album) {
// Use first artist only for iTunes search (multi-artist strings like "Kløn, Renzyx" confuse iTunes) // Use first artist only for iTunes search (multi-artist strings like "Kløn, Renzyx" confuse iTunes)
const searchArtist = artist.split(',')[0].trim(); const searchArtist = artist.split(',')[0].trim();
@@ -679,6 +704,7 @@ app.post('/api/download', async (req, res) => {
genre: itunes.genre || track.primaryGenreName || undefined, genre: itunes.genre || track.primaryGenreName || undefined,
artworkUrl: itunes.artworkUrl || track.artworkUrl100 || '', artworkUrl: itunes.artworkUrl || track.artworkUrl100 || '',
explicit: track.trackExplicitness === 'explicit', explicit: track.trackExplicitness === 'explicit',
durationMs: itunes.durationMs || track.durationMs || undefined,
}); });
if (result?.skipped) skipped++; else done++; if (result?.skipped) skipped++; else done++;
updateJob(jobId, { progress: Math.round(((done + skipped) / plTracks.length) * 90) }); updateJob(jobId, { progress: Math.round(((done + skipped) / plTracks.length) * 90) });
+5 -4
View File
@@ -294,10 +294,11 @@ export async function executeDownload() {
itunesId: S.downloadTarget.trackId || S.downloadTarget.collectionId || null, itunesId: S.downloadTarget.trackId || S.downloadTarget.collectionId || null,
collectionId: S.downloadTarget.collectionId || null, collectionId: S.downloadTarget.collectionId || null,
explicit: S.downloadTarget.trackExplicitness === 'explicit', explicit: S.downloadTarget.trackExplicitness === 'explicit',
trackNum: S.downloadTarget.trackNumber || undefined, trackNum: S.downloadTarget.trackNumber || undefined,
totalTracks: S.downloadTarget.trackCount || undefined, totalTracks: S.downloadTarget.trackCount || undefined,
discNum: S.downloadTarget.discNumber || undefined, discNum: S.downloadTarget.discNumber || undefined,
discTotal: S.downloadTarget.discCount || undefined, discTotal: S.downloadTarget.discCount || undefined,
durationMs: S.downloadTarget.trackTimeMillis || undefined,
}; };
addJob(jobId, payload.title, payload.artist, payload.type); addJob(jobId, payload.title, payload.artist, payload.type);
try { try {