feat(backend): Mittelweg cascade — never download wrong song on no duration match

When a track's duration is known but nothing matches the duration window, the
cascade no longer falls back to blind title-only/no-filter grabs (which pulled
same-titled videos by other uploaders, e.g. a "recognize" fan clip). Instead it
relaxes only the duration constraint while still REQUIRING the artist channel +
title. If nothing matches, the download produces no file and the job now fails
cleanly with "Nicht eindeutig gefunden — bitte Direktlink im Importer nutzen"
(previously it falsely reported success with no file).

Single, album and playlist paths all surface the not-found state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-06-19 18:13:59 +02:00
1 parent cf6e75b312
commit a870f50c87
3 files changed
+23 -29

No files matched your search

+3 -1
View File
@@ -33,7 +33,9 @@ async function downloadAlbumTracks({ jobId, artist, albumTitle, tracks, artworkU
outputDir: CONFIG.musicPath, outputDir: CONFIG.musicPath,
durationMs: track.trackTimeMillis || undefined, durationMs: track.trackTimeMillis || undefined,
}); });
if (result?.skipped) skipped++; else done++; if (result?.skipped) skipped++;
else if (result?.notFound) log(jobId, `⚠️ Nicht eindeutig gefunden: "${track.trackName || track.title}" — Direktlink nutzen`);
else done++;
updateJob(jobId, { progress: Math.round(((done + skipped) / total) * 90) }); updateJob(jobId, { progress: Math.round(((done + skipped) / total) * 90) });
} catch (e) { } catch (e) {
log(jobId, `⚠️ Fehler bei "${track.trackName || track.title}": ${e.message}`); log(jobId, `⚠️ Fehler bei "${track.trackName || track.title}": ${e.message}`);
+12 -25
View File
@@ -295,32 +295,19 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
// 4. YT top-10 — no channel check // 4. YT top-10 — no channel check
await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(dur, true), ...baseArgs]); await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(dur, true), ...baseArgs]);
} }
// No duration match. "Mittelweg": relax the duration constraint but REQUIRE the artist
// channel (+ title). This catches legit tracks whose YT upload duration drifts from the
// iTunes value, while never grabbing a same-titled video by someone else. If nothing
// matches title+channel we download NOTHING and the job fails cleanly (see end of fn) —
// far better than silently saving the wrong song (e.g. a "recognize" fan clip).
if (!hasAudio()) { if (!hasAudio()) {
// 6. YTM all — no duration check // 5. YTM all — no duration, but channel + title required
log(jobId, '⚠️ Kein Treffer im Dauerbereich — suche ohne Dauerbeschränkung…'); log(jobId, '⚠️ Kein Treffer im Dauerbereich — suche ohne Dauer, aber mit Künstler-Channel…');
await tryYtm(0, mf(null, true)); await tryYtm(0, mf(null, true, true));
} }
if (!hasAudio()) { if (!hasAudio()) {
// 7. YT top-5 — no duration check // 6. YT top-10 — no duration, but channel + title required
await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, true), ...baseArgs]); await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(null, true, true), ...baseArgs]);
}
if (!hasAudio()) {
// 8. YTM all — title only (no variant filter)
log(jobId, '⚠️ Letzter gefilterter Versuch…');
await tryYtm(0, mf(null, false));
}
if (!hasAudio()) {
// 9. YT top-5 — title only
await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, false), ...baseArgs]);
}
if (!hasAudio()) {
// 10. YTM — no filter at all, take first video result
log(jobId, '⚠️ Letzter Versuch: keine Filter…');
await tryYtm(3, []);
}
if (!hasAudio()) {
// 11. YT — no filter
await spawnYtdlp([`ytsearch1:${query}`, ...PI_UNFILTERED, ...baseArgs]);
} }
} else { } else {
log(jobId, `🔍 Suche YT Music: "${query}"`); log(jobId, `🔍 Suche YT Music: "${query}"`);
@@ -360,8 +347,8 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
const audioFile = hasAudio() ? expectedAudio : null; const audioFile = hasAudio() ? expectedAudio : null;
if (!audioFile) { if (!audioFile) {
log(jobId, '⚠️ Keine Audio-Datei gefunden'); log(jobId, '⚠️ Nicht eindeutig gefunden — bitte Direktlink im Importer nutzen');
return { success: true, dir }; return { success: false, notFound: true, dir };
} }
return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, rawArtist, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title: searchTitle }); return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, rawArtist, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title: searchTitle });
+8 -3
View File
@@ -601,6 +601,8 @@ app.post('/api/download', async (req, res) => {
const result = await downloadTrack({ jobId, artist: finalArtist, rawArtist: artist, title, ...meta, sourceUrl: sourceUrl || sp.sourceUrl || undefined }); const result = await downloadTrack({ jobId, artist: finalArtist, rawArtist: artist, title, ...meta, sourceUrl: sourceUrl || sp.sourceUrl || undefined });
if (result?.skipped) { if (result?.skipped) {
updateJob(jobId, { status: 'done', progress: 100, message: 'Bereits vorhanden – übersprungen', skipped: true }); updateJob(jobId, { status: 'done', progress: 100, message: 'Bereits vorhanden – übersprungen', skipped: true });
} else if (result?.notFound) {
updateJob(jobId, { status: 'error', message: 'Nicht eindeutig gefunden — bitte Direktlink im Importer nutzen' });
} else { } else {
updateJob(jobId, { status: 'done', progress: 100, message: 'Fertig!' }); updateJob(jobId, { status: 'done', progress: 100, message: 'Fertig!' });
} }
@@ -653,7 +655,7 @@ app.post('/api/download', async (req, res) => {
const plTracks = req.body.tracks || []; const plTracks = req.body.tracks || [];
if (!plTracks.length) throw new Error('Keine Tracks übergeben'); if (!plTracks.length) throw new Error('Keine Tracks übergeben');
log(jobId, `🎵 Playlist "${title}" — ${plTracks.length} Tracks`); log(jobId, `🎵 Playlist "${title}" — ${plTracks.length} Tracks`);
let done = 0, skipped = 0; let done = 0, skipped = 0, notFound = 0;
const CONC = CONFIG.maxParallel || 2; const CONC = CONFIG.maxParallel || 2;
for (let i = 0; i < plTracks.length; i += CONC) { for (let i = 0; i < plTracks.length; i += CONC) {
const batch = plTracks.slice(i, i + CONC); const batch = plTracks.slice(i, i + CONC);
@@ -709,10 +711,13 @@ app.post('/api/download', async (req, res) => {
log(jobId, `⚠️ Fehler bei "${batch[j].trackName || '?'}": ${r.reason?.message || r.reason}`); log(jobId, `⚠️ Fehler bei "${batch[j].trackName || '?'}": ${r.reason?.message || r.reason}`);
} else if (r.value?.skipped) { } else if (r.value?.skipped) {
skipped++; skipped++;
} else if (r.value?.notFound) {
notFound++;
log(jobId, `⚠️ Nicht eindeutig gefunden: "${batch[j].trackName || '?'}" — Direktlink nutzen`);
} else { } else {
done++; done++;
} }
updateJob(jobId, { progress: Math.round(((done + skipped) / plTracks.length) * 90) }); updateJob(jobId, { progress: Math.round(((done + skipped + notFound) / plTracks.length) * 90) });
} }
} }
const allSkipped = skipped === plTracks.length; const allSkipped = skipped === plTracks.length;
@@ -720,7 +725,7 @@ app.post('/api/download', async (req, res) => {
status: 'done', progress: 100, skipped: allSkipped, status: 'done', progress: 100, skipped: allSkipped,
message: allSkipped message: allSkipped
? `Alle ${skipped} Tracks bereits vorhanden` ? `Alle ${skipped} Tracks bereits vorhanden`
: `Fertig! ${done} geladen${skipped ? `, ${skipped} übersprungen` : ''}`, : `Fertig! ${done} geladen${skipped ? `, ${skipped} übersprungen` : ''}${notFound ? `, ${notFound} nicht gefunden` : ''}`,
}); });
} }
} catch (err) { } catch (err) {