diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index f61ebbe..7424da8 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -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 { diff --git a/src/ui.js b/src/ui.js index 4dc9044..40a279f 100644 --- a/src/ui.js +++ b/src/ui.js @@ -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 {