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:
1 parent
dccf8a5dab
commit
fb02d3faf4
2 files changed
+46
-19
No files matched your search
+41
-15
@@ -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) });
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user