diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index d8e1550..0a7d7bd 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -115,8 +115,23 @@ function findExistingTrack(dir, trackPrefix, safeTitle) { .find(f => fs.existsSync(f)) || null; } +// Fetch plain + synced lyrics from LRCLIB +async function fetchLyrics(artist, title, album) { + try { + const p = new URLSearchParams({ artist_name: artist, track_name: title }); + if (album) p.set('album_name', album); + const r = await fetch(`https://lrclib.net/api/get?${p}`, { + headers: { 'User-Agent': 'NovaSonic/1.0' }, + signal: AbortSignal.timeout(8000), + }); + if (!r.ok) return null; + const d = await r.json(); + return { plain: d.plainLyrics || null, synced: d.syncedLyrics || null }; + } catch { return null; } +} + // 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, outputDir }) { +async function downloadTrack({ jobId, artist, albumArtist, title, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, outputDir }) { const mainArtist = albumArtist || artist; const safeArtist = sanitize(mainArtist); const safeAlbum = sanitize(album || 'Singles'); @@ -181,9 +196,20 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t return { success: true, dir }; } - // Step 3: download hi-res iTunes artwork - const coverPath = await downloadArtwork(artworkUrl, dir); - const hasCover = !!(coverPath && fs.existsSync(coverPath)); + // Step 3: download artwork + fetch lyrics in parallel + const [coverPath, lyricsData] = await Promise.all([ + downloadArtwork(artworkUrl, dir), + fetchLyrics(artist, title, album), + ]); + const hasCover = !!(coverPath && fs.existsSync(coverPath)); + + if (lyricsData?.synced) { + const lrcPath = path.join(dir, `${trackPrefix}${safeTitle}.lrc`); + fs.writeFileSync(lrcPath, lyricsData.synced, 'utf8'); + log(jobId, '🎵 Synced lyrics gefunden'); + } else if (lyricsData?.plain) { + log(jobId, '📝 Lyrics gefunden'); + } log(jobId, `🏷 Metadaten + Cover → ${path.basename(audioFile)}`); @@ -213,6 +239,8 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t if (genre) ffArgs.push('-metadata', `genre=${genre}`); if (discNum) ffArgs.push('-metadata', `disc=${discTotal ? `${discNum}/${discTotal}` : String(discNum)}`); if (compilation) ffArgs.push('-metadata', `compilation=1`); + if (explicit) ffArgs.push('-metadata', 'ITUNESADVISORY=1'); + if (lyricsData?.plain) ffArgs.push('-metadata', `lyrics=${lyricsData.plain}`); if (hasCover) { ffArgs.push( '-metadata:s:v', 'title=Album cover', @@ -263,6 +291,7 @@ async function downloadAlbumTracks({ jobId, artist, albumTitle, tracks, artworkU compilation: track.collectionType === 'Compilation' || undefined, genre: track.primaryGenreName || undefined, artworkUrl: artworkUrl || track.artworkUrl100, + explicit: track.trackExplicitness === 'explicit', outputDir: CONFIG.musicPath, }); if (result?.skipped) skipped++; else done++; @@ -278,7 +307,7 @@ async function downloadAlbumTracks({ jobId, artist, albumTitle, tracks, artworkU async function searchItunesAlbumTracks(artist, album) { return new Promise(resolve => { const q = encodeURIComponent(`${artist} ${album}`); - https.get(`https://itunes.apple.com/search?term=${q}&media=music&entity=album&limit=5&country=de`, res => { + https.get(`https://itunes.apple.com/search?term=${q}&media=music&entity=album&limit=5&country=us&explicit=Yes`, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => { @@ -299,7 +328,7 @@ async function searchItunesAlbumTracks(artist, album) { // Fetch individual tracks for an iTunes collectionId async function fetchItunesTracks(collectionId) { return new Promise((resolve, reject) => { - const url = `https://itunes.apple.com/lookup?id=${collectionId}&entity=song`; + const url = `https://itunes.apple.com/lookup?id=${collectionId}&entity=song&explicit=Yes`; https.get(url, res => { let data = ''; res.on('data', d => data += d); @@ -336,7 +365,7 @@ app.get('/api/health', (req, res) => { }); app.post('/api/download', async (req, res) => { - const { type, artist, title, album, artworkUrl, collectionId, jobId: clientJobId } = req.body; + const { type, artist, title, album, artworkUrl, collectionId, jobId: clientJobId, explicit } = req.body; if (!artist || !title) return res.status(400).json({ error: 'artist and title required' }); const jobId = clientJobId || ('job_' + Date.now()); @@ -351,7 +380,7 @@ app.post('/api/download', async (req, res) => { updateJob(jobId, { status: 'running', progress: 5, message: 'Gestartet…' }); try { if (type === 'track') { - const result = await downloadTrack({ jobId, artist, title, album, artworkUrl }); + const result = await downloadTrack({ jobId, artist, title, album, artworkUrl, explicit: !!explicit }); 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 8e96f87..060a48e 100644 --- a/src/ui.js +++ b/src/ui.js @@ -262,6 +262,7 @@ export async function executeDownload() { artworkUrl: S.downloadTarget.artworkUrl100 || S.downloadTarget.artworkUrl60 || '', itunesId: S.downloadTarget.trackId || S.downloadTarget.collectionId || null, collectionId: S.downloadTarget.collectionId || null, + explicit: S.downloadTarget.trackExplicitness === 'explicit', }; addJob(jobId, payload.title, payload.artist); try { diff --git a/src/views.js b/src/views.js index be38182..4a30636 100644 --- a/src/views.js +++ b/src/views.js @@ -128,7 +128,7 @@ export function makeGlobalTrackItem(t, idx, list) { el.innerHTML = `