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:
HoneyandClaude Sonnet 4.6 committed 2026-04-28 18:03:05 +02:00
1 parent 2b1eff2762
commit cc7fc80c71
2 files changed
+54 -24

No files matched your search

+49 -20
View File
@@ -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 {
+5 -4
View File
@@ -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 {