diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index 4f47a7e..24e2710 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -156,12 +156,13 @@ async function lookupItunesTrackMeta(artist, title) { discTotal: match.discCount || undefined, genre: match.primaryGenreName || undefined, artworkUrl: (match.artworkUrl100 || '').replace('100x100bb', '1000x1000bb'), + durationMs: match.trackTimeMillis || undefined, }; } catch { return {}; } } // 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 safeArtist = sanitize(mainArtist.split(',')[0].trim()); 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 query = `${mainArtist} - ${title} official audio`; - log(jobId, `🔍 Suche: "${query}"`); // Step 1: yt-dlp — audio only, no thumbnail (we embed iTunes artwork ourselves) - await new Promise((resolve, reject) => { - const args = [ - `ytsearch1:${query}`, - '--extract-audio', - '--audio-format', CONFIG.audioFmt, - '--audio-quality', CONFIG.audioQuality, - '-o', outputTemplate, - '--no-playlist', - '--playlist-items', '1', - '--no-warnings', - '--newline', - ]; + const baseArgs = [ + '--extract-audio', + '--audio-format', CONFIG.audioFmt, + '--audio-quality', CONFIG.audioQuality, + '-o', outputTemplate, + '--no-playlist', + '--playlist-items', '1', + '--no-warnings', + '--newline', + ]; + + const spawnYtdlp = (args) => new Promise((resolve, reject) => { const proc = spawn(CONFIG.ytdlpBin, args, { env: { ...process.env, PYTHONUNBUFFERED: '1' } }); let stderr = '', lastPct = ''; 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) const audioFile = fs.readdirSync(dir) .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, explicit: track.trackExplicitness === 'explicit', outputDir: CONFIG.musicPath, + durationMs: track.trackTimeMillis || undefined, }); if (result?.skipped) skipped++; else done++; 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, trackExplicitness: d?.contentRating?.label === 'EXPLICIT' || d?.isExplicit || d?.explicit ? 'explicit' : 'notExplicit', 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, discNum: sp.discNumber || bodyDiscNum || 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) { // Use first artist only for iTunes search (multi-artist strings like "Kløn, Renzyx" confuse iTunes) const searchArtist = artist.split(',')[0].trim(); @@ -679,6 +704,7 @@ app.post('/api/download', async (req, res) => { genre: itunes.genre || track.primaryGenreName || undefined, artworkUrl: itunes.artworkUrl || track.artworkUrl100 || '', explicit: track.trackExplicitness === 'explicit', + durationMs: itunes.durationMs || track.durationMs || undefined, }); if (result?.skipped) skipped++; else done++; updateJob(jobId, { progress: Math.round(((done + skipped) / plTracks.length) * 90) }); 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 {