/** * 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] }; } // undici (native fetch) cannot abort a pending TCP connect even with AbortController. // Use Node's https module with socket.setTimeout() — fires at OS level during connect. function _fetchTimeout(url, opts = {}, ms = 10000) { return new Promise((resolve, reject) => { const u = new URL(url); const label = u.hostname + u.pathname.slice(0, 40); const done = (fn) => { if (!done.called) { done.called = true; fn(); } }; const req = https.request({ hostname: u.hostname, port: 443, path: u.pathname + u.search, method: opts.method || 'GET', headers: { 'User-Agent': 'NovaSonic/1.0', 'Accept': 'application/json', ...opts.headers, }, }, res => { console.log(`[sp-http] ${res.statusCode} ${label}`); const chunks = []; res.on('data', c => chunks.push(c)); res.on('end', () => { const body = Buffer.concat(chunks).toString('utf8'); done(() => resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, headers: { get: h => res.headers[h.toLowerCase()] ?? null }, json: () => { try { return Promise.resolve(JSON.parse(body)); } catch(e) { return Promise.reject(e); } }, text: () => Promise.resolve(body), })); }); res.on('error', e => done(() => reject(e))); }); // Two-layer timeout: req-level (fires after ms of total inactivity) + socket-level req.setTimeout(ms, () => req.destroy(new Error(`Spotify timeout ${ms}ms: ${label}`))); req.on('socket', s => s.setTimeout(ms, () => req.destroy(new Error(`Spotify socket timeout ${ms}ms: ${label}`)))); req.on('error', e => done(() => reject(e))); if (opts.body) req.write(opts.body); req.end(); }); } let _spTokenCache = null; let _spTokenExpiry = 0; async function fetchSpotifyToken() { if (_spTokenCache && Date.now() < _spTokenExpiry) return _spTokenCache; if (!CONFIG.spotifyClientId || !CONFIG.spotifyClientSecret) { throw new Error( 'Spotify-Credentials fehlen. Bitte SPOTIFY_CLIENT_ID und ' + 'SPOTIFY_CLIENT_SECRET in der Umgebung setzen. ' + 'Setup-Anleitung: https://developer.spotify.com/dashboard' ); } const basic = Buffer.from(`${CONFIG.spotifyClientId}:${CONFIG.spotifyClientSecret}`).toString('base64'); const r = await _fetchTimeout('https://accounts.spotify.com/api/token', { method: 'POST', headers: { 'Authorization': `Basic ${basic}`, 'Content-Type': 'application/x-www-form-urlencoded', }, body: 'grant_type=client_credentials', }); if (!r.ok) { const errBody = await r.text().catch(() => ''); throw new Error(`Spotify-Token-Fehler (${r.status}): ${errBody.slice(0, 200)}`); } const d = await r.json(); if (!d.access_token) throw new Error('Kein access_token von Spotify erhalten'); _spTokenCache = d.access_token; _spTokenExpiry = Date.now() + (d.expires_in * 1000) - 30000; console.log('[spotify] token ok'); return _spTokenCache; } async function _spApi(path, token) { const url = path.startsWith('http') ? path : `https://api.spotify.com/v1${path}`; for (let attempt = 0; attempt < 4; attempt++) { const r = await _fetchTimeout(url, { headers: { Authorization: `Bearer ${token}` } }); if (r.status === 429) { const wait = (parseInt(r.headers.get('Retry-After') || '5', 10) + 1) * 1000; await new Promise(res => setTimeout(res, wait)); continue; } if (!r.ok) throw new Error(`Spotify API Fehler ${r.status}: ${path}`); return r.json(); } throw new Error('Spotify API rate-limited — bitte kurz warten und erneut versuchen'); } async function _spAllPlaylistTracks(id, token) { const tracks = []; let skippedLocal = 0; let path = `/playlists/${id}/tracks?limit=100`; let pages = 0; while (path && pages++ < 100) { const page = await _spApi(path, token); for (const item of page.items || []) { const t = item.track; if (!t || !t.id) continue; if (t.is_local) { skippedLocal++; continue; } tracks.push(t); } path = page.next || null; } return { tracks, skippedLocal }; } async function _spAllAlbumTracks(id, token) { const tracks = []; let path = `/albums/${id}/tracks?limit=50`; let pages = 0; while (path && pages++ < 100) { const page = await _spApi(path, token); tracks.push(...(page.items || []).filter(t => t?.id)); path = page.next || null; } return tracks; } function _spArtwork(entity) { // Real API: entity.images[] or entity.album.images[] const imgs = entity?.images || entity?.album?.images || []; if (imgs.length) return imgs.sort((a, b) => (b.width || 0) - (a.width || 0))[0]?.url || ''; return ''; } function _spAllArtists(artistsRaw) { if (!artistsRaw) return ''; const arr = Array.isArray(artistsRaw) ? artistsRaw : (artistsRaw.items || []); return arr.map(a => a?.profile?.name || a?.name || '').filter(Boolean).join(', '); } function _spArtistName(artistsRaw) { return _spAllArtists(artistsRaw); } function _spTrackItem(t, defaultAlbum, defaultArt, defaultAlbumArtist) { const trackName = t?.name || ''; const artist = _spAllArtists(t?.artists) || defaultAlbumArtist || ''; const art = _spArtwork(t) || defaultArt || ''; const albumObj = t?.album || {}; const album = albumObj?.name || defaultAlbum || ''; const date = albumObj?.release_date || ''; return { trackName, artistName: artist, collectionName: album, artworkUrl100: art, releaseDate: date, trackNumber: t?.track_number, trackExplicitness: t?.explicit ? 'explicit' : 'notExplicit', previewUrl: t?.preview_url || null, durationMs: t?.duration_ms || null, }; } // ─── 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 }); }); 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: !!(CONFIG.spotifyClientId && CONFIG.spotifyClientSecret), }, }); }); async function _resolveSpotify(type, id) { const token = await fetchSpotifyToken(); if (type === 'track') { const t = await _spApi(`/tracks/${id}`, token); const artist = _spArtistName(t.artists); const artUrl = _spArtwork(t); return { type: 'track', name: t.name || '', artist, artworkUrl: artUrl, tracks: [_spTrackItem(t, t.album?.name, artUrl, artist)] }; } if (type === 'album') { const album = await _spApi(`/albums/${id}`, token); const artist = _spArtistName(album.artists); const artUrl = _spArtwork(album); const trackItems = await _spAllAlbumTracks(id, token); return { type: 'album', name: album.name || '', artist, artworkUrl: artUrl, tracks: trackItems.map(t => _spTrackItem(t, album.name, artUrl, artist)) }; } if (type === 'playlist') { const pl = await _spApi(`/playlists/${id}?fields=name,description,owner,images`, token); const owner = pl.owner?.display_name || ''; const artUrl = _spArtwork(pl); const { tracks: trackItems, skippedLocal } = await _spAllPlaylistTracks(id, token); return { type: 'playlist', name: pl.name || '', artist: owner, artworkUrl: artUrl, description: pl.description || '', skippedLocal, tracks: trackItems.map(t => _spTrackItem(t, null, null, null)) }; } throw new Error(`Unbekannter Typ: ${type}`); } app.post('/api/spotify/resolve', async (req, res) => { const { url } = req.body; if (!url) return res.status(400).json({ error: 'url required' }); 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 (>25s)')), 25000) ); const data = await Promise.race([_resolveSpotify(type, id), 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' }); } 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 || '', 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