/** * 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 */ 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', 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; } next(); }); app.use(express.json()); const jobs = new Map(); 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!'); } 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); } // Convert "Artist feat. B" / "Artist ft. B" / "Artist featuring B" → "Artist / B" // so Navidrome splits on "/" and lists each as a separate artist function navidromeArtist(name) { if (!name) return name; return name .replace(/\s+feat\.?\s+/gi, ' / ') .replace(/\s+ft\.?\s+/gi, ' / ') .replace(/\s+featuring\s+/gi, ' / '); } 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 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); }); } const AUDIO_EXTS = ['mp3', 'flac', 'opus', 'm4a', 'ogg', 'wav']; function findExistingTrack(dir, trackPrefix, safeTitle) { return AUDIO_EXTS .map(ext => path.join(dir, `${trackPrefix}${safeTitle}.${ext}`)) .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; } } // iTunes track lookup — fills in album, year, trackNum, totalTracks, genre when missing async function lookupItunesTrackMeta(artist, title) { try { const url = `https://itunes.apple.com/search?term=${encodeURIComponent(artist + ' ' + title)}&media=music&entity=musicTrack&limit=5&country=us&explicit=Yes`; const results = await new Promise((resolve, reject) => { https.get(url, 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 norm = s => s?.toLowerCase().trim() || ''; const match = results.find(t => norm(t.trackName) === norm(title) && norm(t.artistName).split(' ')[0] === norm(artist).split(' ')[0] ) || results[0]; if (!match) return {}; return { album: match.collectionName || '', year: match.releaseDate ? new Date(match.releaseDate).getFullYear() : undefined, trackNum: match.trackNumber || undefined, totalTracks: match.trackCount || undefined, discNum: match.discNumber || undefined, discTotal: match.discCount || undefined, genre: match.primaryGenreName || undefined, artworkUrl: (match.artworkUrl100 || '').replace('100x100bb', '1000x1000bb'), }; } 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 }) { 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 }); const safeTitle = sanitize(title); const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : ''; const existing = findExistingTrack(dir, trackPrefix, safeTitle); if (existing) { log(jobId, `⏭ Bereits vorhanden: ${path.basename(existing)} — übersprungen`); return { success: true, dir, skipped: true }; } 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 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 wrote (newest audio file in dir) const audioFile = fs.readdirSync(dir) .filter(f => AUDIO_EXTS.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, '⚠️ Keine Audio-Datei gefunden'); return { success: true, dir }; } // 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)}`); // 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]; 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', '-1', // strip all existing tags (removes blurry YT thumbnail) '-id3v2_version', '3', '-metadata', `title=${title}`, '-metadata', `artist=${navidromeArtist(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 (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', '-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()); ff.on('close', code => { if (code === 0 && fs.existsSync(tmpFile)) { fs.renameSync(tmpFile, audioFile); log(jobId, `✅ Fertig${hasCover ? ' (Cover 1000x1000 eingebettet)' : ''}`); } else { try { fs.unlinkSync(tmpFile); } catch {} log(jobId, `⚠️ ffmpeg code ${code}`); if (ffErr) log(jobId, ffErr.slice(-200)); } resolve(); }); }); return { success: true, dir }; } // 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`); let done = 0, skipped = 0; for (const track of tracks) { try { const result = await downloadTrack({ jobId, 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, trackNum: track.trackNumber || undefined, totalTracks: total, discNum: track.discNumber || undefined, discTotal: track.discCount || undefined, 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++; updateJob(jobId, { progress: Math.round(((done + skipped) / total) * 90) }); } catch (e) { log(jobId, `⚠️ Fehler bei "${track.trackName || track.title}": ${e.message}`); } } return { done, skipped }; } // 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}`); 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', () => { try { const results = JSON.parse(d).results || []; const match = results.find(r => r.collectionName?.toLowerCase().includes(album.toLowerCase()) || r.artistName?.toLowerCase().includes(artist.toLowerCase()) ); if (match) fetchItunesTracks(match.collectionId).then(resolve).catch(() => resolve([])); else resolve([]); } catch { resolve([]); } }); }).on('error', () => resolve([])); }); } // 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&explicit=Yes`; 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); }); } // ─── 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(); } // ─── SPOTIFY ────────────────────────────────────────────────────────────────── let _spTok = null, _spTokExp = 0, _spCachedId = ''; async function getSpotifyToken(clientId, clientSecret) { if (_spTok && clientId === _spCachedId && Date.now() < _spTokExp - 30000) return _spTok; const creds = Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); const r = await fetch('https://accounts.spotify.com/api/token', { method: 'POST', headers: { Authorization: `Basic ${creds}`, 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=client_credentials', signal: AbortSignal.timeout(8000), }); if (!r.ok) throw new Error(`Spotify Auth fehlgeschlagen (${r.status}) — Client ID/Secret prüfen`); const d = await r.json(); _spTok = d.access_token; _spTokExp = Date.now() + d.expires_in * 1000; _spCachedId = clientId; return _spTok; } function parseSpotifyUrl(url) { const m = url.match(/open\.spotify\.com\/(track|album|playlist)\/([A-Za-z0-9]+)/); if (!m) throw new Error('Ungültige Spotify-URL — erwartet: open.spotify.com/track|album|playlist/...'); return { type: m[1], id: m[2] }; } // ─── ROUTES ────────────────────────────────────────────────────────────────── app.get('/api/health', (req, res) => { res.json({ status: 'ok', ytdlpVersion, musicPath: CONFIG.musicPath, audioFormat: CONFIG.audioFmt, activeJobs: activeDownloads, queueLength: downloadQueue.length }); }); app.post('/api/spotify/resolve', async (req, res) => { const { url, clientId, clientSecret } = req.body; if (!url) return res.status(400).json({ error: 'url required' }); if (!clientId || !clientSecret) return res.status(400).json({ error: 'Spotify credentials fehlen (clientId + clientSecret in Einstellungen eintragen)' }); try { const { type, id } = parseSpotifyUrl(url); const tok = await getSpotifyToken(clientId, clientSecret); const spH = { Authorization: `Bearer ${tok}` }; const spFetch = (u) => fetch(u, { headers: spH, signal: AbortSignal.timeout(10000) }); if (type === 'track') { const r = await spFetch(`https://api.spotify.com/v1/tracks/${id}`); if (!r.ok) throw new Error(`Spotify API ${r.status}`); const d = await r.json(); return res.json({ type: 'track', name: d.name, artist: d.artists?.[0]?.name || '', artworkUrl: d.album?.images?.[0]?.url || '', tracks: [{ trackName: d.name, artistName: d.artists?.[0]?.name || '', collectionName: d.album?.name || '', artworkUrl100: d.album?.images?.[0]?.url || '', releaseDate: d.album?.release_date || '', trackNumber: d.track_number, trackExplicitness: d.explicit ? 'explicit' : 'notExplicit', previewUrl: d.preview_url || null }] }); } if (type === 'album') { const r = await spFetch(`https://api.spotify.com/v1/albums/${id}`); if (!r.ok) throw new Error(`Spotify API ${r.status}`); const d = await r.json(); const art = d.images?.[0]?.url || ''; return res.json({ type: 'album', name: d.name, artist: d.artists?.[0]?.name || '', artworkUrl: art, tracks: (d.tracks?.items || []).map(t => ({ trackName: t.name, artistName: t.artists?.[0]?.name || d.artists?.[0]?.name || '', collectionName: d.name, artworkUrl100: art, releaseDate: d.release_date || '', trackNumber: t.track_number, trackExplicitness: t.explicit ? 'explicit' : 'notExplicit', previewUrl: t.preview_url || null })) }); } if (type === 'playlist') { const r = await spFetch(`https://api.spotify.com/v1/playlists/${id}?fields=name,description,images,owner,tracks.total`); if (!r.ok) throw new Error(`Spotify API ${r.status}`); const pl = await r.json(); const tracks = []; let nextUrl = `https://api.spotify.com/v1/playlists/${id}/tracks?limit=100&fields=next,items(is_local,track(name,artists,album,track_number,explicit,preview_url))`; while (nextUrl) { const tr = await spFetch(nextUrl); if (!tr.ok) break; const td = await tr.json(); for (const item of (td.items || [])) { if (!item?.track || item.is_local) continue; tracks.push({ trackName: item.track.name, artistName: item.track.artists?.[0]?.name || '', collectionName: item.track.album?.name || '', artworkUrl100: item.track.album?.images?.[0]?.url || '', releaseDate: item.track.album?.release_date || '', trackNumber: item.track.track_number, trackExplicitness: item.track.explicit ? 'explicit' : 'notExplicit', previewUrl: item.track.preview_url || null }); } nextUrl = td.next || null; } return res.json({ type: 'playlist', name: pl.name, artist: pl.owner?.display_name || '', artworkUrl: pl.images?.[0]?.url || '', description: pl.description || '', tracks }); } res.status(400).json({ error: `Unbekannter Typ: ${type}` }); } catch(e) { console.error('[spotify/resolve]', e.message); res.status(500).json({ error: e.message }); } }); app.post('/api/download', async (req, res) => { 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()); 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(async () => { updateJob(jobId, { status: 'running', progress: 5, message: 'Gestartet…' }); try { if (type === 'track') { let meta = { album, year: undefined, trackNum: undefined, totalTracks: undefined, discNum: undefined, discTotal: undefined, genre: undefined }; if (!album) { log(jobId, '📋 Suche Album-Metadaten von iTunes…'); const extra = await lookupItunesTrackMeta(artist, title); if (extra.album) log(jobId, `📋 Album: "${extra.album}" (${extra.year || '?'})`); meta = { ...extra, artworkUrl: artworkUrl || extra.artworkUrl || '' }; } const result = await downloadTrack({ jobId, artist, title, artworkUrl: artworkUrl || meta.artworkUrl || '', explicit: !!explicit, ...meta }); if (result?.skipped) { updateJob(jobId, { status: 'done', progress: 100, message: 'Bereits vorhanden – übersprungen', skipped: true }); } else { 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) { log(jobId, '🔍 Suche Album auf iTunes…'); tracks = await searchItunesAlbumTracks(artist, album || title); } if (!tracks.length) { throw new Error(`Keine einzelnen Tracks für "${album || title}" gefunden. Bitte über iTunes Global-Suche herunterladen.`); } const { done: dlDone, skipped: dlSkipped } = await downloadAlbumTracks({ jobId, artist, albumTitle: album || title, tracks, artworkUrl }); const allSkipped = dlSkipped === tracks.length; const someSkipped = dlSkipped > 0 && !allSkipped; const msg = allSkipped ? `Album bereits vorhanden – alle ${dlSkipped} Tracks übersprungen` : someSkipped ? `Album fertig! (${dlDone} geladen, ${dlSkipped} bereits vorhanden)` : `Album "${album || title}" fertig!`; updateJob(jobId, { status: 'done', progress: 100, message: msg, skipped: allSkipped }); } else if (type === 'artist') { log(jobId, `🎤 Künstler-Download: ${artist}`); 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); log(jobId, `📀 ${artistAlbums.length} 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 / artistAlbums.length) * 95) }); } updateJob(jobId, { status: 'done', progress: 100, message: `Künstler "${artist}" fertig!` }); } else if (type === 'playlist') { const plTracks = req.body.tracks || []; if (!plTracks.length) throw new Error('Keine Tracks übergeben'); log(jobId, `🎵 Playlist "${title}" — ${plTracks.length} Tracks`); let done = 0, skipped = 0; for (const track of plTracks) { try { const result = await downloadTrack({ jobId, artist: track.artistName || artist || '', albumArtist: track.artistName || artist || '', title: track.trackName || '', album: track.collectionName || '', year: track.releaseDate ? new Date(track.releaseDate).getFullYear() : undefined, trackNum: track.trackNumber || undefined, genre: track.primaryGenreName || undefined, artworkUrl: track.artworkUrl100 || artworkUrl || '', explicit: track.trackExplicitness === 'explicit', }); if (result?.skipped) skipped++; else done++; updateJob(jobId, { progress: Math.round(((done + skipped) / plTracks.length) * 90) }); } catch(e) { log(jobId, `⚠️ Fehler bei "${track.trackName || '?'}": ${e.message}`); } } const allSkipped = skipped === plTracks.length; updateJob(jobId, { status: 'done', progress: 100, skipped: allSkipped, message: allSkipped ? `Alle ${skipped} Tracks bereits vorhanden` : `Fertig! ${done} geladen${skipped ? `, ${skipped} übersprungen` : ''}`, }); } } catch (err) { console.error(`[${jobId}] Error:`, err.message); updateJob(jobId, { status: 'error', message: err.message }); } }); }); 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); }); 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(''); });