diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index 3208868..5004cb4 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -6,12 +6,6 @@ * - Node.js 18+ * - yt-dlp installed (https://github.com/yt-dlp/yt-dlp) * - ffmpeg installed - * - * Install & run: - * npm install express cors - * node novasonic-server.js - * - * Or with Docker - see docker-compose.yml */ const express = require('express'); @@ -22,45 +16,42 @@ const https = require('https'); // ─── CONFIG ─────────────────────────────────────────────────────────────────── const CONFIG = { - port: process.env.PORT || 3700, - musicPath: process.env.MUSIC_PATH || '/music', // Navidrome music library folder - ytdlpBin: process.env.YTDLP_BIN || 'yt-dlp', // Path to yt-dlp binary - audioFmt: process.env.AUDIO_FMT || 'mp3', // mp3 | flac | opus | best - audioQuality: process.env.AUDIO_QUALITY || '0', // 0 = best + port: process.env.PORT || 3700, + musicPath: process.env.MUSIC_PATH || '/music', + ytdlpBin: process.env.YTDLP_BIN || 'yt-dlp', + audioFmt: process.env.AUDIO_FMT || 'mp3', + audioQuality: process.env.AUDIO_QUALITY || '0', maxParallel: parseInt(process.env.MAX_PARALLEL || '2'), }; // ─── INIT ───────────────────────────────────────────────────────────────────── const app = express(); -app.use((req,res,next)=>{ - res.setHeader('Access-Control-Allow-Origin','*'); - res.setHeader('Access-Control-Allow-Methods','GET,POST,OPTIONS'); - res.setHeader('Access-Control-Allow-Headers','Content-Type,Authorization'); - res.setHeader('Access-Control-Allow-Private-Network','true'); - if(req.method==='OPTIONS'){res.sendStatus(204);return;} +app.use((req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization'); + res.setHeader('Access-Control-Allow-Private-Network', 'true'); + if (req.method === 'OPTIONS') { res.sendStatus(204); return; } next(); }); app.use(express.json()); -// In-memory job store const jobs = new Map(); -// Check yt-dlp on startup let ytdlpVersion = 'unknown'; try { ytdlpVersion = execSync(`${CONFIG.ytdlpBin} --version`, { timeout: 5000 }).toString().trim(); console.log(`✅ yt-dlp ${ytdlpVersion} found`); } catch { - console.warn('⚠️ yt-dlp not found! Install from https://github.com/yt-dlp/yt-dlp'); + console.warn('⚠️ yt-dlp not found!'); } -// Ensure music dir exists if (!fs.existsSync(CONFIG.musicPath)) { console.warn(`⚠️ Music path "${CONFIG.musicPath}" does not exist — creating it`); fs.mkdirSync(CONFIG.musicPath, { recursive: true }); } -// ─── HELPERS ───────────────────────────────────────────────────────────────── +// ─── HELPERS ────────────────────────────────────────────────────────────────── function sanitize(s) { return (s || 'Unknown').replace(/[\\/:*?"<>|]/g, '_').trim().slice(0, 100); } @@ -76,12 +67,40 @@ function log(jobId, msg) { console.log(`[${jobId}] ${msg}`); } -// Download a single track via yt-dlp, then stamp metadata via a dedicated ffmpeg pass. -// We do NOT use --postprocessor-args for metadata because yt-dlp splits that string on -// spaces internally, so any metadata value with a space (e.g. album name) gets corrupted. -// Instead: yt-dlp downloads, then we call ffmpeg ourselves with properly separated args. -async function downloadTrack({ jobId, artist, title, album, year, trackNum, totalTracks, artworkUrl, outputDir }) { - const safeArtist = sanitize(artist); +// Download hi-res iTunes artwork (1000x1000) to cover.jpg, return path or null +async function downloadArtwork(url, dir) { + if (!url) return null; + const dest = path.join(dir, 'cover.jpg'); + // Upgrade to 1000x1000 — iTunes URL pattern: ...100x100bb.jpg + const hiRes = url.replace(/\d+x\d+bb\./, '1000x1000bb.'); + return new Promise(resolve => { + const tryUrl = (u, fallback) => { + const file = fs.createWriteStream(dest); + https.get(u, res => { + if (res.statusCode !== 200) { + res.resume(); + file.close(); + try { fs.unlinkSync(dest); } catch {} + if (fallback) tryUrl(fallback, null); + else resolve(null); + return; + } + res.pipe(file); + file.on('finish', () => resolve(dest)); + file.on('error', () => resolve(null)); + }).on('error', () => { + if (fallback) tryUrl(fallback, null); + else resolve(null); + }); + }; + tryUrl(hiRes, url !== hiRes ? url : 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, artworkUrl, outputDir }) { + const mainArtist = albumArtist || artist; + const safeArtist = sanitize(mainArtist); const safeAlbum = sanitize(album || 'Singles'); const dir = path.join(outputDir || CONFIG.musicPath, safeArtist, safeAlbum); fs.mkdirSync(dir, { recursive: true }); @@ -89,21 +108,19 @@ async function downloadTrack({ jobId, artist, title, album, year, trackNum, tota const safeTitle = sanitize(title); const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : ''; const outputTemplate = path.join(dir, `${trackPrefix}${safeTitle}.%(ext)s`); - const query = `${artist} - ${title} official audio`; - log(jobId, `\uD83D\uDD0D Suche: "${query}"`); + const query = `${mainArtist} - ${title} official audio`; + log(jobId, `🔍 Suche: "${query}"`); - // Step 1: yt-dlp – download + extract audio + embed YT thumbnail + // 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, - '--embed-thumbnail', - '--convert-thumbnails', 'jpg', + '--audio-format', CONFIG.audioFmt, + '--audio-quality', CONFIG.audioQuality, + '-o', outputTemplate, '--no-playlist', - '--playlist-items', '1', + '--playlist-items', '1', '--no-warnings', '--newline', ]; @@ -128,7 +145,7 @@ async function downloadTrack({ jobId, artist, title, album, year, trackNum, tota ); }); - // Step 2: find the audio file yt-dlp just wrote (newest audio file in dir) + // Step 2: find the audio file yt-dlp wrote (newest audio file in dir) const audioExts = ['mp3', 'flac', 'opus', 'm4a', 'ogg', 'wav']; const audioFile = fs.readdirSync(dir) .filter(f => audioExts.some(e => f.endsWith(`.${e}`))) @@ -136,97 +153,79 @@ async function downloadTrack({ jobId, artist, title, album, year, trackNum, tota .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0]; if (!audioFile) { - log(jobId, '\u26A0\uFE0F Keine Audio-Datei gefunden – \xFCberspringe Metadaten'); - if (artworkUrl) downloadCoverArt(dir, artworkUrl); + log(jobId, '⚠️ Keine Audio-Datei gefunden'); return { success: true, dir }; } - log(jobId, `\uD83C\uDFF7 Schreibe Metadaten \u2192 ${path.basename(audioFile)}`); + // Step 3: download hi-res iTunes artwork + const coverPath = await downloadArtwork(artworkUrl, dir); + const hasCover = !!(coverPath && fs.existsSync(coverPath)); - // Step 3: ffmpeg – rewrite ID3 tags. Each -metadata is a separate arg so spaces are safe. + log(jobId, `🏷 Metadaten + Cover → ${path.basename(audioFile)}`); + + // Step 4: ffmpeg — strip old tags, write clean ID3, embed hi-res artwork const fileExt = path.extname(audioFile); const tmpFile = audioFile.slice(0, -fileExt.length) + '.metatmp' + fileExt; await new Promise(resolve => { - const ffArgs = [ - '-i', audioFile, + const ffArgs = ['-i', audioFile]; + if (hasCover) ffArgs.push('-i', coverPath); + + // Map: audio from input 0, artwork from input 1 (if available) + ffArgs.push('-map', '0:a'); + if (hasCover) ffArgs.push('-map', '1:v'); + + ffArgs.push( '-c', 'copy', - '-map_metadata', '0', + '-map_metadata', '-1', // strip all existing tags (removes blurry YT thumbnail) '-id3v2_version', '3', '-metadata', `title=${title}`, '-metadata', `artist=${artist}`, - '-metadata', `album_artist=${artist}`, - ]; + '-metadata', `album_artist=${mainArtist}`, + ); if (album) ffArgs.push('-metadata', `album=${album}`); if (year) ffArgs.push('-metadata', `date=${String(year)}`); if (trackNum) ffArgs.push('-metadata', `track=${totalTracks ? `${trackNum}/${totalTracks}` : String(trackNum)}`); if (totalTracks) ffArgs.push('-metadata', `totaltracks=${String(totalTracks)}`); + if (hasCover) { + ffArgs.push( + '-metadata:s:v', 'title=Album cover', + '-metadata:s:v', 'comment=Cover (front)', + ); + } ffArgs.push(tmpFile, '-y'); const ff = spawn('ffmpeg', ffArgs); let ffErr = ''; ff.stderr.on('data', d => ffErr += d.toString()); - ff.on('error', () => resolve()); // ffmpeg not installed? skip gracefully + ff.on('error', () => resolve()); ff.on('close', code => { if (code === 0 && fs.existsSync(tmpFile)) { fs.renameSync(tmpFile, audioFile); - log(jobId, '\u2705 Metadaten geschrieben (title, artist, album, year, track)'); + log(jobId, `✅ Fertig${hasCover ? ' (Cover 1000x1000 eingebettet)' : ''}`); } else { try { fs.unlinkSync(tmpFile); } catch {} - log(jobId, `\u26A0\uFE0F ffmpeg Metadaten-Schritt: code ${code}`); + log(jobId, `⚠️ ffmpeg code ${code}`); if (ffErr) log(jobId, ffErr.slice(-200)); } resolve(); }); }); - // Step 4: save cover.jpg for Navidrome folder-art - if (artworkUrl) downloadCoverArt(dir, artworkUrl); return { success: true, dir }; } -// Fallback: embed thumbnail via separate ffmpeg call (for formats where yt-dlp fails) -async function embedThumbnailFallback(dir, safeTitle, artworkUrl, jobId) { - if (!artworkUrl) return; - const coverPath = path.join(dir, 'cover.jpg'); - if (!fs.existsSync(coverPath)) return; - const ext = CONFIG.audioFmt; - const files = fs.readdirSync(dir).filter(f => f.startsWith(safeTitle) && f.endsWith(`.${ext}`)); - if (!files.length) return; - const audioFile = path.join(dir, files[0]); - const tmpFile = audioFile + '.tmp.' + ext; - return new Promise(resolve => { - const proc = spawn('ffmpeg', [ - '-i', audioFile, '-i', coverPath, - '-map', '0', '-map', '1', - '-c', 'copy', '-id3v2_version', '3', - '-metadata:s:v', 'title=Album cover', - '-metadata:s:v', 'comment=Cover (front)', - tmpFile, '-y' - ]); - proc.on('close', code => { - if (code === 0) { - fs.renameSync(tmpFile, audioFile); - log(jobId, '🖼 Cover eingebettet'); - } else { - try { fs.unlinkSync(tmpFile); } catch {} - } - resolve(); - }); - proc.on('error', () => resolve()); - }); -} - -// Download multiple tracks (album/artist) +// Download all tracks of an album sequentially async function downloadAlbumTracks({ jobId, artist, albumTitle, tracks, artworkUrl }) { const total = tracks.length; - log(jobId, `📀 Album "${albumTitle}" – ${total} Tracks`); + log(jobId, `📀 Album "${albumTitle}" — ${total} Tracks`); let done = 0; for (const track of tracks) { try { await downloadTrack({ jobId, - artist: track.artistName || artist, + artist: track.artistName || artist, // track artist (may include feat.) + albumArtist: artist, // always the main artist → consistent grouping title: track.trackName || track.title, album: albumTitle, year: track.releaseDate ? new Date(track.releaseDate).getFullYear() : undefined, @@ -238,12 +237,12 @@ async function downloadAlbumTracks({ jobId, artist, albumTitle, tracks, artworkU done++; updateJob(jobId, { progress: Math.round((done / total) * 90) }); } catch (e) { - log(jobId, `⚠️ Fehler bei "${track.trackName}": ${e.message}`); + log(jobId, `⚠️ Fehler bei "${track.trackName || track.title}": ${e.message}`); } } } -// Search iTunes for an album by artist+name and return its tracks +// Search iTunes for album tracks by artist + album name (fallback when no collectionId) async function searchItunesAlbumTracks(artist, album) { return new Promise(resolve => { const q = encodeURIComponent(`${artist} ${album}`); @@ -265,7 +264,7 @@ async function searchItunesAlbumTracks(artist, album) { }); } -// Fetch iTunes album tracks +// 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`; @@ -282,19 +281,7 @@ async function fetchItunesTracks(collectionId) { }); } -// Save cover.jpg into folder for Navidrome folder-art detection -function downloadCoverArt(dir, url) { - if (!url) return; - try { - const dest = path.join(dir, 'cover.jpg'); - if (fs.existsSync(dest)) return; - const file = fs.createWriteStream(dest); - const imgUrl = url.replace('100x100', '600x600').replace('60x60', '600x600'); - https.get(imgUrl, res => res.pipe(file)).on('error', () => {}); - } catch {} -} - -// Active download queue +// ─── QUEUE ──────────────────────────────────────────────────────────────────── let activeDownloads = 0; const downloadQueue = []; @@ -312,25 +299,13 @@ function enqueue(task) { // ─── ROUTES ────────────────────────────────────────────────────────────────── -// Health check app.get('/api/health', (req, res) => { - res.json({ - status: 'ok', - ytdlpVersion, - musicPath: CONFIG.musicPath, - audioFormat: CONFIG.audioFmt, - activeJobs: activeDownloads, - queueLength: downloadQueue.length, - }); + res.json({ status: 'ok', ytdlpVersion, musicPath: CONFIG.musicPath, audioFormat: CONFIG.audioFmt, activeJobs: activeDownloads, queueLength: downloadQueue.length }); }); -// Submit download job app.post('/api/download', async (req, res) => { const { type, artist, title, album, artworkUrl, collectionId, jobId: clientJobId } = req.body; - - if (!artist || !title) { - return res.status(400).json({ error: 'artist and title required' }); - } + if (!artist || !title) return res.status(400).json({ error: 'artist and title required' }); const jobId = clientJobId || ('job_' + Date.now()); jobs.set(jobId, { @@ -338,13 +313,10 @@ app.post('/api/download', async (req, res) => { status: 'queued', progress: 0, message: 'In Warteschlange…', log: [], createdAt: Date.now(), updatedAt: Date.now(), }); - res.json({ jobId, status: 'queued' }); - // Enqueue the actual work enqueue(async () => { updateJob(jobId, { status: 'running', progress: 5, message: 'Gestartet…' }); - try { if (type === 'track') { await downloadTrack({ jobId, artist, title, album, artworkUrl }); @@ -361,22 +333,20 @@ app.post('/api/download', async (req, res) => { tracks = await searchItunesAlbumTracks(artist, album || title); } if (!tracks.length) { - throw new Error(`Keine einzelnen Tracks für "${album || title}" gefunden. Bitte über die iTunes Global-Suche herunterladen.`); + throw new Error(`Keine einzelnen Tracks für "${album || title}" gefunden. Bitte über iTunes Global-Suche herunterladen.`); } await downloadAlbumTracks({ jobId, artist, albumTitle: album || title, tracks, artworkUrl }); updateJob(jobId, { status: 'done', progress: 100, message: `Album "${album || title}" fertig!` }); } else if (type === 'artist') { log(jobId, `🎤 Künstler-Download: ${artist}`); - // Search for artist's top albums via iTunes const artistData = await new Promise((resolve, reject) => { https.get(`https://itunes.apple.com/search?term=${encodeURIComponent(artist)}&media=music&entity=album&limit=10&country=de`, res => { - let d = '';res.on('data', c=>d+=c);res.on('end',()=>{try{resolve(JSON.parse(d).results||[])}catch{reject(new Error('parse'))}}); - }).on('error',reject); + let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve(JSON.parse(d).results || []); } catch { reject(new Error('parse')); } }); + }).on('error', reject); }); const artistAlbums = artistData.filter(a => a.artistName?.toLowerCase() === artist.toLowerCase()).slice(0, 10); - const total = artistAlbums.length || 1; - log(jobId, `📀 ${total} Alben gefunden`); + log(jobId, `📀 ${artistAlbums.length} Alben gefunden`); let done = 0; for (const alb of artistAlbums) { const tracks = await fetchItunesTracks(alb.collectionId).catch(() => []); @@ -384,11 +354,10 @@ app.post('/api/download', async (req, res) => { await downloadAlbumTracks({ jobId, artist, albumTitle: alb.collectionName, tracks, artworkUrl: alb.artworkUrl100 }); } done++; - updateJob(jobId, { progress: Math.round((done / total) * 95) }); + updateJob(jobId, { progress: Math.round((done / artistAlbums.length) * 95) }); } - updateJob(jobId, { status: 'done', progress: 100, message: `Künstler "${artist}" vollständig heruntergeladen!` }); + updateJob(jobId, { status: 'done', progress: 100, message: `Künstler "${artist}" fertig!` }); } - } catch (err) { console.error(`[${jobId}] Error:`, err.message); updateJob(jobId, { status: 'error', message: err.message }); @@ -396,14 +365,12 @@ app.post('/api/download', async (req, res) => { }); }); -// Job status app.get('/api/status/:jobId', (req, res) => { const job = jobs.get(req.params.jobId); if (!job) return res.status(404).json({ error: 'Job not found' }); res.json(job); }); -// List all jobs app.get('/api/jobs', (req, res) => { res.json(Array.from(jobs.values()).sort((a, b) => b.createdAt - a.createdAt).slice(0, 50)); }); @@ -415,4 +382,4 @@ app.listen(CONFIG.port, () => { console.log(` Audio format : ${CONFIG.audioFmt} (quality ${CONFIG.audioQuality})`); console.log(` yt-dlp : ${ytdlpVersion}`); console.log(''); -}); \ No newline at end of file +});