/** * NovaSonic Backend Server * Downloads music via yt-dlp and saves directly into your Navidrome music library * * Requirements: * - 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'); const { spawn, execSync } = require('child_process'); const path = require('path'); const fs = require('fs'); 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 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;} 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'); } // 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 ───────────────────────────────────────────────────────────────── function sanitize(s) { return (s || 'Unknown').replace(/[\\/:*?"<>|]/g, '_').trim().slice(0, 100); } function updateJob(id, update) { const job = jobs.get(id); if (job) jobs.set(id, { ...job, ...update, updatedAt: Date.now() }); } function log(jobId, msg) { const j = jobs.get(jobId); if (j) jobs.set(jobId, { ...j, log: [...(j.log || []), msg], message: 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, artworkUrl, outputDir }) { const safeArtist = sanitize(artist); const safeAlbum = sanitize(album || 'Singles'); const dir = path.join(outputDir || CONFIG.musicPath, safeArtist, safeAlbum); fs.mkdirSync(dir, { recursive: true }); const safeTitle = sanitize(title); const outputTemplate = path.join(dir, `${safeTitle}.%(ext)s`); const query = `${artist} - ${title} official audio`; log(jobId, `\uD83D\uDD0D Suche: "${query}"`); // Step 1: yt-dlp – download + extract audio + embed YT thumbnail 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', '--no-playlist', '--playlist-items', '1', '--no-warnings', '--newline', ]; const proc = spawn(CONFIG.ytdlpBin, args, { env: { ...process.env, PYTHONUNBUFFERED: '1' } }); let stderr = '', lastPct = ''; proc.stdout.on('data', d => { d.toString().split('\n').filter(l => l.trim()).forEach(line => { if (line.includes('%') && line === lastPct) return; if (line.includes('%')) lastPct = line; log(jobId, line.trim()); }); }); proc.stderr.on('data', d => { stderr += d.toString(); d.toString().split('\n') .filter(l => l.trim() && !l.startsWith('[debug]')) .forEach(l => log(jobId, l.trim())); }); proc.on('error', reject); proc.on('close', code => code === 0 ? resolve() : reject(new Error(`yt-dlp code ${code}: ${stderr.slice(-300)}`)) ); }); // Step 2: find the audio file yt-dlp just 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}`))) .map(f => path.join(dir, f)) .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); return { success: true, dir }; } log(jobId, `\uD83C\uDFF7 Schreibe Metadaten \u2192 ${path.basename(audioFile)}`); // Step 3: ffmpeg – rewrite ID3 tags. Each -metadata is a separate arg so spaces are safe. const fileExt = path.extname(audioFile); const tmpFile = audioFile.slice(0, -fileExt.length) + '.metatmp' + fileExt; await new Promise(resolve => { const ffArgs = [ '-i', audioFile, '-c', 'copy', '-map_metadata', '0', '-id3v2_version', '3', '-metadata', `title=${title}`, '-metadata', `artist=${artist}`, '-metadata', `album_artist=${artist}`, ]; if (album) ffArgs.push('-metadata', `album=${album}`); if (year) ffArgs.push('-metadata', `date=${String(year)}`); if (trackNum) ffArgs.push('-metadata', `track=${String(trackNum)}`); 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('close', code => { if (code === 0 && fs.existsSync(tmpFile)) { fs.renameSync(tmpFile, audioFile); log(jobId, '\u2705 Metadaten geschrieben (title, artist, album, year, track)'); } else { try { fs.unlinkSync(tmpFile); } catch {} log(jobId, `\u26A0\uFE0F ffmpeg Metadaten-Schritt: 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) async function downloadAlbumTracks({ jobId, artist, albumTitle, tracks, artworkUrl }) { const total = tracks.length; log(jobId, `📀 Album "${albumTitle}" – ${total} Tracks`); let done = 0; for (const track of tracks) { try { await downloadTrack({ jobId, artist: track.artistName || artist, title: track.trackName || track.title, album: albumTitle, year: track.releaseDate ? new Date(track.releaseDate).getFullYear() : undefined, trackNum: track.trackNumber || undefined, artworkUrl: artworkUrl || track.artworkUrl100, outputDir: CONFIG.musicPath, }); done++; updateJob(jobId, { progress: Math.round((done / total) * 90) }); } catch (e) { log(jobId, `⚠️ Fehler bei "${track.trackName}": ${e.message}`); } } } // Fetch iTunes album tracks async function fetchItunesTracks(collectionId) { return new Promise((resolve, reject) => { const url = `https://itunes.apple.com/lookup?id=${collectionId}&entity=song`; https.get(url, res => { let data = ''; res.on('data', d => data += d); res.on('end', () => { try { const parsed = JSON.parse(data); resolve((parsed.results || []).filter(r => r.wrapperType === 'track')); } catch { reject(new Error('iTunes API parse error')); } }); }).on('error', reject); }); } // 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 let activeDownloads = 0; const downloadQueue = []; function processQueue() { if (activeDownloads >= CONFIG.maxParallel || downloadQueue.length === 0) return; const task = downloadQueue.shift(); activeDownloads++; task().finally(() => { activeDownloads--; processQueue(); }); } function enqueue(task) { downloadQueue.push(task); processQueue(); } // ─── 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, }); }); // 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' }); } const jobId = clientJobId || ('job_' + Date.now()); jobs.set(jobId, { id: jobId, type, artist, title, album, 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 }); updateJob(jobId, { status: 'done', progress: 100, message: 'Fertig!' }); } else if (type === 'album') { let tracks = []; if (collectionId) { log(jobId, '📋 Lade Track-Liste von iTunes…'); tracks = await fetchItunesTracks(collectionId); } if (tracks.length) { await downloadAlbumTracks({ jobId, artist, albumTitle: album || title, tracks, artworkUrl }); } else { // Fallback: download full album search await downloadTrack({ jobId, artist, title: `${album || title} full album`, album, 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); }); const artistAlbums = artistData.filter(a => a.artistName?.toLowerCase() === artist.toLowerCase()).slice(0, 10); const total = artistAlbums.length || 1; log(jobId, `📀 ${total} Alben gefunden`); let done = 0; for (const alb of artistAlbums) { const tracks = await fetchItunesTracks(alb.collectionId).catch(() => []); if (tracks.length) { await downloadAlbumTracks({ jobId, artist, albumTitle: alb.collectionName, tracks, artworkUrl: alb.artworkUrl100 }); } done++; updateJob(jobId, { progress: Math.round((done / total) * 95) }); } updateJob(jobId, { status: 'done', progress: 100, message: `Künstler "${artist}" vollständig heruntergeladen!` }); } } catch (err) { console.error(`[${jobId}] Error:`, err.message); updateJob(jobId, { status: 'error', message: err.message }); } }); }); // 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)); }); // ─── START ──────────────────────────────────────────────────────────────────── app.listen(CONFIG.port, () => { console.log(`\n🎵 NovaSonic Backend running on port ${CONFIG.port}`); console.log(` Music folder : ${CONFIG.musicPath}`); console.log(` Audio format : ${CONFIG.audioFmt} (quality ${CONFIG.audioQuality})`); console.log(` yt-dlp : ${ytdlpVersion}`); console.log(''); });