fix: improve yt-dlp search reliability and artist folder structure
- Switch to YouTube Music search (ytmsearch3) as primary source with fallback to ytsearch5 if no result found - Add duration filter (±30s/+45s) and live-stream exclusion via --match-filter when iTunes trackTimeMillis is available - Send trackTimeMillis from frontend in download payload - Pass durationMs through downloadTrack and downloadAlbumTracks - Use first artist only (split on comma) for folder path on multi-artist tracks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
2b1eff2762
commit
cc7fc80c71
2 files changed
+54
-24
No files matched your search
+49
-20
@@ -161,7 +161,7 @@ async function lookupItunesTrackMeta(artist, title) {
|
||||
}
|
||||
|
||||
// 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, durationMs, compilation, genre, artworkUrl, explicit, outputDir }) {
|
||||
const mainArtist = albumArtist || artist;
|
||||
const safeArtist = sanitize(mainArtist);
|
||||
const safeAlbum = sanitize(album || 'Singles');
|
||||
@@ -178,19 +178,26 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t
|
||||
}
|
||||
|
||||
const outputTemplate = path.join(dir, `${trackPrefix}${safeTitle}.%(ext)s`);
|
||||
const query = `${mainArtist} - ${title} official audio`;
|
||||
log(jobId, `🔍 Suche: "${query}"`);
|
||||
const query = `${mainArtist} - ${title}`;
|
||||
|
||||
// Step 1: yt-dlp — audio only, no thumbnail (we embed iTunes artwork ourselves)
|
||||
await new Promise((resolve, reject) => {
|
||||
// Build match-filter: always exclude live streams; also constrain duration when known (±30s low, +45s high)
|
||||
const durationSec = durationMs ? Math.round(durationMs / 1000) : null;
|
||||
const matchFilter = [
|
||||
'!is_live',
|
||||
durationSec && `duration > ${Math.max(0, durationSec - 30)}`,
|
||||
durationSec && `duration < ${durationSec + 45}`,
|
||||
].filter(Boolean).join(' & ');
|
||||
|
||||
const runYtdlp = (searchExpr) => new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
`ytsearch1:${query}`,
|
||||
searchExpr,
|
||||
'--extract-audio',
|
||||
'--audio-format', CONFIG.audioFmt,
|
||||
'--audio-quality', CONFIG.audioQuality,
|
||||
'-o', outputTemplate,
|
||||
'--audio-format', CONFIG.audioFmt,
|
||||
'--audio-quality', CONFIG.audioQuality,
|
||||
'-o', outputTemplate,
|
||||
'--no-playlist',
|
||||
'--playlist-items', '1',
|
||||
'--match-filter', matchFilter,
|
||||
'--max-downloads', '1',
|
||||
'--no-warnings',
|
||||
'--newline',
|
||||
];
|
||||
@@ -210,11 +217,30 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t
|
||||
.forEach(l => log(jobId, l.trim()));
|
||||
});
|
||||
proc.on('error', reject);
|
||||
// code 101 = --max-downloads limit reached (= success); 0 = completed normally
|
||||
proc.on('close', code =>
|
||||
code === 0 ? resolve() : reject(new Error(`yt-dlp code ${code}: ${stderr.slice(-300)}`))
|
||||
(code === 0 || code === 101) ? resolve() : reject(new Error(`yt-dlp code ${code}: ${stderr.slice(-300)}`))
|
||||
);
|
||||
});
|
||||
|
||||
const audioFileCount = () =>
|
||||
fs.readdirSync(dir).filter(f => AUDIO_EXTS.some(e => f.endsWith(`.${e}`))).length;
|
||||
|
||||
// Step 1: YouTube Music — music-specific index, far fewer false positives
|
||||
log(jobId, `🎵 YouTube Music: "${query}"`);
|
||||
const countBefore = audioFileCount();
|
||||
try {
|
||||
await runYtdlp(`ytmsearch3:${query}`);
|
||||
} catch (e) {
|
||||
log(jobId, `⚠️ YouTube Music Fehler: ${e.message}`);
|
||||
}
|
||||
|
||||
// Step 2: Fallback to regular YouTube if no file was produced
|
||||
if (audioFileCount() === countBefore) {
|
||||
log(jobId, `🔄 Fallback: YouTube (5 Ergebnisse) — "${query}"`);
|
||||
await runYtdlp(`ytsearch5:${query}`);
|
||||
}
|
||||
|
||||
// Step 2: find the audio file yt-dlp wrote (newest audio file in dir)
|
||||
const audioFile = fs.readdirSync(dir)
|
||||
.filter(f => AUDIO_EXTS.some(e => f.endsWith(`.${e}`)))
|
||||
@@ -314,10 +340,11 @@ async function downloadAlbumTracks({ jobId, artist, albumTitle, tracks, artworkU
|
||||
title: track.trackName || track.title,
|
||||
album: albumTitle,
|
||||
year: track.releaseDate ? new Date(track.releaseDate).getFullYear() : undefined,
|
||||
trackNum: track.trackNumber || undefined,
|
||||
trackNum: track.trackNumber || undefined,
|
||||
totalTracks: total,
|
||||
discNum: track.discNumber || undefined,
|
||||
discTotal: track.discCount || undefined,
|
||||
discNum: track.discNumber || undefined,
|
||||
discTotal: track.discCount || undefined,
|
||||
durationMs: track.trackTimeMillis || undefined,
|
||||
compilation: track.collectionType === 'Compilation' || undefined,
|
||||
genre: track.primaryGenreName || undefined,
|
||||
artworkUrl: artworkUrl || track.artworkUrl100,
|
||||
@@ -565,7 +592,8 @@ app.post('/api/spotify/resolve', async (req, res) => {
|
||||
|
||||
app.post('/api/download', async (req, res) => {
|
||||
const { type, artist, title, album, artworkUrl, collectionId, jobId: clientJobId, explicit,
|
||||
trackNum: bodyTrackNum, totalTracks: bodyTotalTracks, discNum: bodyDiscNum, discTotal: bodyDiscTotal } = req.body;
|
||||
trackNum: bodyTrackNum, totalTracks: bodyTotalTracks, discNum: bodyDiscNum, discTotal: bodyDiscTotal,
|
||||
durationMs: bodyDurationMs } = req.body;
|
||||
if (!artist || !title) return res.status(400).json({ error: 'artist and title required' });
|
||||
|
||||
const jobId = clientJobId || ('job_' + Date.now());
|
||||
@@ -586,10 +614,11 @@ app.post('/api/download', async (req, res) => {
|
||||
const spArt = sp.artworkUrl100 || artworkUrl || '';
|
||||
const spYear = sp.releaseDate ? new Date(sp.releaseDate).getFullYear() : undefined;
|
||||
let meta = { album: spAlbum, year: spYear,
|
||||
trackNum: sp.trackNumber || bodyTrackNum || undefined,
|
||||
totalTracks: sp.trackCount || bodyTotalTracks || undefined,
|
||||
discNum: sp.discNumber || bodyDiscNum || undefined,
|
||||
discTotal: sp.discCount || bodyDiscTotal || undefined,
|
||||
trackNum: sp.trackNumber || bodyTrackNum || undefined,
|
||||
totalTracks: sp.trackCount || bodyTotalTracks || undefined,
|
||||
discNum: sp.discNumber || bodyDiscNum || undefined,
|
||||
discTotal: sp.discCount || bodyDiscTotal || undefined,
|
||||
durationMs: sp.trackTimeMillis || bodyDurationMs || undefined,
|
||||
artworkUrl: spArt, explicit: sp.trackExplicitness === 'explicit' || !!explicit };
|
||||
if (!meta.album) {
|
||||
// Use first artist only for iTunes search (multi-artist strings like "Kløn, Renzyx" confuse iTunes)
|
||||
@@ -601,7 +630,7 @@ app.post('/api/download', async (req, res) => {
|
||||
} else {
|
||||
log(jobId, `📋 Album aus Spotify: "${meta.album}"`);
|
||||
}
|
||||
const result = await downloadTrack({ jobId, artist, title, ...meta });
|
||||
const result = await downloadTrack({ jobId, artist, albumArtist: artist.split(',')[0].trim(), title, ...meta });
|
||||
if (result?.skipped) {
|
||||
updateJob(jobId, { status: 'done', progress: 100, message: 'Bereits vorhanden – übersprungen', skipped: true });
|
||||
} else {
|
||||
|
||||
@@ -294,10 +294,11 @@ export async function executeDownload() {
|
||||
itunesId: S.downloadTarget.trackId || S.downloadTarget.collectionId || null,
|
||||
collectionId: S.downloadTarget.collectionId || null,
|
||||
explicit: S.downloadTarget.trackExplicitness === 'explicit',
|
||||
trackNum: S.downloadTarget.trackNumber || undefined,
|
||||
totalTracks: S.downloadTarget.trackCount || undefined,
|
||||
discNum: S.downloadTarget.discNumber || undefined,
|
||||
discTotal: S.downloadTarget.discCount || undefined,
|
||||
trackNum: S.downloadTarget.trackNumber || undefined,
|
||||
totalTracks: S.downloadTarget.trackCount || undefined,
|
||||
discNum: S.downloadTarget.discNumber || undefined,
|
||||
discTotal: S.downloadTarget.discCount || undefined,
|
||||
durationMs: S.downloadTarget.trackTimeMillis || undefined,
|
||||
};
|
||||
addJob(jobId, payload.title, payload.artist, payload.type);
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user