diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index 338d4ac..f61ebbe 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -161,10 +161,9 @@ 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, durationMs, compilation, genre, artworkUrl, explicit, outputDir }) { +async function downloadTrack({ jobId, artist, albumArtist, title, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, outputDir }) { const mainArtist = albumArtist || artist; - // use only the first artist for the folder path (e.g. "Artist1, Artist2" → "Artist1") - const safeArtist = sanitize(mainArtist.split(',')[0].trim()); + const safeArtist = sanitize(mainArtist); const safeAlbum = sanitize(album || 'Singles'); const dir = path.join(outputDir || CONFIG.musicPath, safeArtist, safeAlbum); fs.mkdirSync(dir, { recursive: true }); @@ -179,26 +178,19 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t } const outputTemplate = path.join(dir, `${trackPrefix}${safeTitle}.%(ext)s`); - const query = `${mainArtist} - ${title}`; + const query = `${mainArtist} - ${title} official audio`; + log(jobId, `🔍 Suche: "${query}"`); - // 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) => { + // Step 1: yt-dlp — audio only, no thumbnail (we embed iTunes artwork ourselves) + await new Promise((resolve, reject) => { const args = [ - searchExpr, + `ytsearch1:${query}`, '--extract-audio', - '--audio-format', CONFIG.audioFmt, - '--audio-quality', CONFIG.audioQuality, - '-o', outputTemplate, + '--audio-format', CONFIG.audioFmt, + '--audio-quality', CONFIG.audioQuality, + '-o', outputTemplate, '--no-playlist', - '--match-filter', matchFilter, - '--max-downloads', '1', + '--playlist-items', '1', '--no-warnings', '--newline', ]; @@ -218,33 +210,11 @@ 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 || code === 101) ? resolve() : reject(new Error(`yt-dlp code ${code}: ${stderr.slice(-300)}`)) + code === 0 ? 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 - // Falls silently if this yt-dlp version doesn't support ytmsearch (older Alpine package) - const countBefore = audioFileCount(); - try { - log(jobId, `🎵 YouTube Music: "${query}"`); - await runYtdlp(`ytmsearch:${query}`); - } catch (e) { - if (!e.message.includes('Unsupported url scheme')) { - log(jobId, `⚠️ YouTube Music Fehler: ${e.message}`); - } - } - - // Step 2: Fallback to regular YouTube if no file was produced - if (audioFileCount() === countBefore) { - log(jobId, `🔍 YouTube: "${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}`))) @@ -344,11 +314,10 @@ 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, - durationMs: track.trackTimeMillis || undefined, + discNum: track.discNumber || undefined, + discTotal: track.discCount || undefined, compilation: track.collectionType === 'Compilation' || undefined, genre: track.primaryGenreName || undefined, artworkUrl: artworkUrl || track.artworkUrl100, @@ -596,8 +565,7 @@ 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, - durationMs: bodyDurationMs } = req.body; + trackNum: bodyTrackNum, totalTracks: bodyTotalTracks, discNum: bodyDiscNum, discTotal: bodyDiscTotal } = req.body; if (!artist || !title) return res.status(400).json({ error: 'artist and title required' }); const jobId = clientJobId || ('job_' + Date.now()); @@ -618,11 +586,10 @@ 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, - durationMs: sp.trackTimeMillis || bodyDurationMs || undefined, + trackNum: sp.trackNumber || bodyTrackNum || undefined, + totalTracks: sp.trackCount || bodyTotalTracks || undefined, + discNum: sp.discNumber || bodyDiscNum || undefined, + discTotal: sp.discCount || bodyDiscTotal || 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) @@ -634,7 +601,7 @@ app.post('/api/download', async (req, res) => { } else { log(jobId, `📋 Album aus Spotify: "${meta.album}"`); } - const result = await downloadTrack({ jobId, artist, albumArtist: artist.split(',')[0].trim(), title, ...meta }); + const result = await downloadTrack({ jobId, artist, title, ...meta }); if (result?.skipped) { updateJob(jobId, { status: 'done', progress: 100, message: 'Bereits vorhanden – übersprungen', skipped: true }); } else { diff --git a/docker-compose.yml b/docker-compose.yml index 6e73d9d..d0ab856 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,8 +30,7 @@ services: - MAX_PARALLEL=${MAX_PARALLEL:-2} command: > sh -c " - apk add --no-cache ffmpeg python3 py3-pip && - pip3 install -U yt-dlp --break-system-packages && + apk add --no-cache yt-dlp ffmpeg python3 && npm install express cors && node novasonic-server.js " diff --git a/src/ui.js b/src/ui.js index 40a279f..4dc9044 100644 --- a/src/ui.js +++ b/src/ui.js @@ -294,11 +294,10 @@ 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, - durationMs: S.downloadTarget.trackTimeMillis || undefined, + trackNum: S.downloadTarget.trackNumber || undefined, + totalTracks: S.downloadTarget.trackCount || undefined, + discNum: S.downloadTarget.discNumber || undefined, + discTotal: S.downloadTarget.discCount || undefined, }; addJob(jobId, payload.title, payload.artist, payload.type); try {