/** * NovaSonic Backend Server * Downloads music via yt-dlp / squid.wtf (Qobuz) and saves into your Navidrome library. * * Requirements: * - Node.js 18+ * - yt-dlp installed (https://github.com/yt-dlp/yt-dlp) * - ffmpeg installed */ const express = require('express'); const { execSync } = require('child_process'); const path = require('path'); const fs = require('fs'); const https = require('https'); const CONFIG = require('./lib/config'); const { jobs, updateJob, log } = require('./lib/jobs'); const { lookupItunesTrackMeta, fetchItunesTracks, searchItunesAlbumTracks } = require('./lib/metadata'); const { downloadTrack, downloadAlbumTracks } = require('./lib/downloader'); // ─── INIT ───────────────────────────────────────────────────────────────────── const app = express(); app.use((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PATCH,DELETE,OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization,X-ABS-URL,X-ABS-Token'); res.setHeader('Access-Control-Allow-Private-Network', 'true'); if (req.method === 'OPTIONS') { res.sendStatus(204); return; } next(); }); app.use(express.json()); 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 }); } // ─── 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 ────────────────────────────────────────────────────────────────── function parseSpotifyUrl(url) { const m = url.match(/open\.spotify\.com\/(?:[a-z]{2,8}-[a-z]{2,8}\/)?(?:intl-[a-z-]+\/)?(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.post('/api/spotify/track-artwork', async (req, res) => { const tracks = req.body.tracks || []; if (!tracks.length) return res.json({ results: [] }); const CONC = 8; const results = []; for (let i = 0; i < tracks.length; i += CONC) { const batch = tracks.slice(i, i + CONC); const batchRes = await Promise.all(batch.map(async t => { const searchArtist = (t.artistName || '').split(/\s*[,/•]\s*/)[0].trim(); const meta = await lookupItunesTrackMeta(searchArtist, t.trackName || ''); return { idx: t.idx, artworkUrl: meta.artworkUrl || t.existingArt || '' }; })); results.push(...batchRes); } res.json({ results }); }); // ─── ITUNES PROXY ───────────────────────────────────────────────────────────── // Browsers can't call itunes.apple.com directly (no CORS headers). Proxy it here. app.get('/api/itunes/:endpoint', (req, res) => { const allowed = new Set(['search', 'lookup']); if (!allowed.has(req.params.endpoint)) return res.status(404).json({ error: 'not found' }); const qs = new URLSearchParams(req.query).toString(); const target = `https://itunes.apple.com/${req.params.endpoint}${qs ? '?' + qs : ''}`; https.get(target, { headers: { 'User-Agent': 'NovaSonic/1.0' } }, upstream => { res.setHeader('Content-Type', 'application/json'); upstream.pipe(res); }).on('error', e => res.status(502).json({ error: e.message })); }); app.get('/api/health', (req, res) => { res.json({ status: 'ok', ytdlpVersion, musicPath: CONFIG.musicPath, audioFormat: CONFIG.audioFmt, downloadBackend: CONFIG.downloadBackend, squidQuality: CONFIG.squidQuality, activeJobs: activeDownloads, queueLength: downloadQueue.length, spotify: { configured: true }, }); }); async function _resolveSpotifyViaSpotdl(url) { const { execFile } = require('child_process'); const scriptPath = path.join(__dirname, 'spotify_resolve.py'); // Strip ?si= and other tracking params — spotdl doesn't handle them const cleanUrl = url.split('?')[0]; return new Promise((resolve, reject) => { execFile('python3', [scriptPath, cleanUrl], { timeout: 90000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => { if (stderr?.trim()) stderr.trim().split('\n').forEach(l => console.log('[py]', l)); // Always try to parse stdout first — script outputs JSON even on error const out = stdout?.trim(); if (out) { try { const data = JSON.parse(out); if (data.error === 'RATE_LIMITED') return reject(new Error(`RATE_LIMITED:${data.retryAfter || 30}`)); if (data.error) return reject(new Error(`spotdl: ${data.error}`)); return resolve(data); } catch { /* not JSON, fall through */ } } if (err) return reject(new Error(`spotdl exited ${err.code}: ${stderr?.trim() || out || err.message}`)); reject(new Error(`spotdl: keine Ausgabe`)); }); }); } // Tracks when the Spotify rate limit window expires so retries never reach Spotify. let _spRateLimitUntil = 0; app.post('/api/spotify/resolve', async (req, res) => { const { url } = req.body; if (!url) return res.status(400).json({ error: 'url required' }); // Block retries until the cooldown the Spotify told us has actually elapsed. const now = Date.now(); if (_spRateLimitUntil > now) { const secs = Math.ceil((_spRateLimitUntil - now) / 1000); console.log(`[spotify/resolve] cooldown active — ${secs}s remaining`); return res.status(429).json({ error: 'RATE_LIMITED', retryAfter: secs }); } try { const { type, id } = parseSpotifyUrl(url); console.log(`[spotify/resolve] ${type} ${id}`); const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Zeitüberschreitung — Spotify API antwortet nicht (>120s)')), 120000) ); const data = await Promise.race([_resolveSpotifyViaSpotdl(url), timeout]); res.json(data); } catch(e) { console.error('[spotify/resolve]', e.message); if (e.message.includes('403') && e.message.includes('/playlists/')) { return res.status(403).json({ error: 'EDITORIAL_PLAYLIST' }); } if (e.message.startsWith('RATE_LIMITED:')) { const secs = parseInt(e.message.split(':')[1], 10) || 30; _spRateLimitUntil = Date.now() + secs * 1000; return res.status(429).json({ error: 'RATE_LIMITED', retryAfter: secs }); } res.status(500).json({ error: e.message }); } }); 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 } = 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') { const sp = req.body.tracks?.[0] || {}; const spAlbum = sp.collectionName || album || ''; 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, artworkUrl: spArt, explicit: sp.trackExplicitness === 'explicit' || !!explicit, durationMs: sp.durationMs || undefined, }; if (!meta.album) { const searchArtist = artist.split(',')[0].trim(); log(jobId, '📋 Suche Album-Metadaten von iTunes…'); const extra = await lookupItunesTrackMeta(searchArtist, title); if (extra.album) log(jobId, `📋 Album: "${extra.album}" (${extra.year || '?'})`); meta = { ...meta, ...extra, artworkUrl: spArt || extra.artworkUrl || '' }; } else { log(jobId, `📋 Album aus Spotify: "${meta.album}"`); } const result = await downloadTrack({ jobId, artist, title, ...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; const CONC = CONFIG.maxParallel || 2; for (let i = 0; i < plTracks.length; i += CONC) { const batch = plTracks.slice(i, i + CONC); const batchResults = await Promise.allSettled(batch.map(async track => { const trackTitle = track.trackName || ''; const trackArtist = track.artistName || artist || ''; const searchArtist = trackArtist.split(',')[0].trim(); log(jobId, `📋 iTunes: "${trackArtist} – ${trackTitle}"`); const itunes = await lookupItunesTrackMeta(searchArtist, trackTitle); return downloadTrack({ jobId, artist: trackArtist, albumArtist: trackArtist, title: trackTitle, album: itunes.album || track.collectionName || '', year: itunes.year || (track.releaseDate ? new Date(track.releaseDate).getFullYear() : undefined), trackNum: itunes.trackNum || track.trackNumber || undefined, totalTracks: itunes.totalTracks || undefined, genre: itunes.genre || track.primaryGenreName || undefined, artworkUrl: itunes.artworkUrl || track.artworkUrl100 || artworkUrl || '', explicit: track.trackExplicitness === 'explicit', durationMs: itunes.durationMs || track.durationMs || undefined, }); })); for (let j = 0; j < batchResults.length; j++) { const r = batchResults[j]; if (r.status === 'rejected') { log(jobId, `⚠️ Fehler bei "${batch[j].trackName || '?'}": ${r.reason?.message || r.reason}`); } else if (r.value?.skipped) { skipped++; } else { done++; } updateJob(jobId, { progress: Math.round(((done + skipped) / plTracks.length) * 90) }); } } 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)); }); // ─── ABS PROXY ─────────────────────────────────────────────────────────────── // Forwards API calls to Audiobookshelf to avoid CORS restrictions in the browser. // The browser sends X-ABS-URL and X-ABS-Token headers; audio streaming uses // direct