Files
NovaSonic/backend/novasonic-server.js
T
HoneyandClaude Sonnet 4.6 8cc6653898 fix: add Spotify web-player headers to API calls, pace paginated requests
Without app-platform/spotify-app-version headers, requests look like
bot traffic and get rate-limited aggressively. Add 300ms delay between
playlist track pages to avoid burst throttling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 00:08:44 +02:00

636 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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',
// Force HTTP/1.1 — api.spotify.com negotiates h2 via ALPN but
// https.request uses HTTP/1.1 framing, causing a silent hang.
ALPNProtocols: ['http/1.1'],
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;
let _spSecretCache = null;
let _spSecretExp = 0;
// Dynamically extract Spotify's TOTP secret from their web-player JS bundle.
async function _fetchSpotifySecret() {
if (_spSecretCache && Date.now() < _spSecretExp) return _spSecretCache;
const htmlRes = await _fetchTimeout('https://open.spotify.com/', {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'identity',
},
}, 20000);
if (!htmlRes.ok) throw new Error(`open.spotify.com ${htmlRes.status}`);
const html = await htmlRes.text();
// Collect JS bundle URLs referenced by the main page
const seen = new Set();
const jsUrls = [];
const srcRe = /src="((?:https?:\/\/[^"]*|\/[^"]*?)\.js(?:\?[^"]*)?)"/g;
let m;
while ((m = srcRe.exec(html)) !== null) {
const url = m[1].startsWith('http') ? m[1] : 'https://open.spotify.com' + m[1];
if (!seen.has(url)) { seen.add(url); jsUrls.push(url); }
}
// Search each bundle for {secret:"...",version:N}
for (const url of jsUrls.slice(0, 15)) {
try {
const r = await _fetchTimeout(url, {
headers: { 'User-Agent': 'Mozilla/5.0', 'Accept': '*/*', 'Accept-Encoding': 'identity' },
}, 8000);
if (!r.ok) continue;
const js = await r.text();
const sm = js.match(/\{secret:\s*["']([^"']+)["']\s*,\s*version:\s*(\d+)\s*\}/);
if (sm) {
const secret = [...sm[1]].map(c => c.charCodeAt(0));
const version = parseInt(sm[2], 10);
_spSecretCache = { secret, version };
_spSecretExp = Date.now() + 60 * 60 * 1000; // cache 1h
console.log(`[spotify] TOTP secret v${version} found`);
return _spSecretCache;
}
} catch { /* try next bundle */ }
}
throw new Error('Spotify TOTP-Secret nicht gefunden');
}
function _generateTotp(secretArray, serverTimestamp) {
const crypto = require('crypto');
// XOR transform each byte with ((index % 33) + 9)
const transformed = secretArray.map((b, i) => b ^ ((i % 33) + 9));
const secretBytes = Buffer.from(Buffer.from(transformed.join(''), 'utf8').toString('hex'), 'hex');
const counter = Math.floor(serverTimestamp / 30);
const counterBuf = Buffer.alloc(8);
counterBuf.writeBigUInt64BE(BigInt(counter));
const digest = crypto.createHmac('sha1', secretBytes).update(counterBuf).digest();
const offset = digest[digest.length - 1] & 0xf;
const code = (
((digest[offset] & 0x7f) << 24) |
((digest[offset + 1] & 0xff) << 16) |
((digest[offset + 2] & 0xff) << 8) |
(digest[offset + 3] & 0xff)
) % 1_000_000;
return code.toString().padStart(6, '0');
}
async function fetchSpotifyToken() {
if (_spTokenCache && Date.now() < _spTokenExpiry) return _spTokenCache;
// Approach 1: TOTP-based anonymous token — no developer credentials needed,
// uses open.spotify.com/api/token with a computed 6-digit code.
try {
const { secret, version } = await _fetchSpotifySecret();
const stRes = await _fetchTimeout('https://open.spotify.com/api/server-time', {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept': 'application/json',
'Referer': 'https://open.spotify.com/',
},
});
if (!stRes.ok) throw new Error(`server-time ${stRes.status}`);
const { serverTime } = await stRes.json();
const totp = _generateTotp(secret, serverTime);
const tRes = await _fetchTimeout(
`https://open.spotify.com/api/token?reason=init&productType=web-player&totp=${totp}&totpVer=${version}&totpServer=${totp}`,
{
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept': 'application/json',
'Referer': 'https://open.spotify.com/',
},
}
);
if (!tRes.ok) throw new Error(`api/token ${tRes.status}`);
const t = await tRes.json();
if (!t.accessToken) throw new Error('no accessToken in response');
_spTokenCache = t.accessToken;
_spTokenExpiry = (t.accessTokenExpirationTimestampMs || (Date.now() + 55 * 60 * 1000)) - 30000;
console.log('[spotify] token ok (TOTP)');
return _spTokenCache;
} catch (e) {
console.log('[spotify] TOTP failed:', e.message);
_spSecretCache = null; // force secret re-extraction next time
}
// Approach 2: Client Credentials fallback (requires SPOTIFY_CLIENT_ID/SECRET in env)
if (!CONFIG.spotifyClientId || !CONFIG.spotifyClientSecret) {
throw new Error(
'Spotify-Token nicht abrufbar (TOTP gescheitert). ' +
'SPOTIFY_CLIENT_ID und SPOTIFY_CLIENT_SECRET setzen als Fallback.'
);
}
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 (Client Credentials)');
return _spTokenCache;
}
async function _spApi(path, token) {
const url = path.startsWith('http') ? path : `https://api.spotify.com/v1${path}`;
const r = await _fetchTimeout(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Origin': 'https://open.spotify.com',
'Referer': 'https://open.spotify.com/',
'app-platform': 'WebPlayer',
'spotify-app-version': '1.2.46.25.g7f189073',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept': 'application/json',
'Accept-Language': 'en-US,en;q=0.9',
},
});
if (r.status === 429) {
const retryAfter = parseInt(r.headers.get('Retry-After') || '10', 10);
_spTokenCache = null;
_spTokenExpiry = 0;
console.log(`[spotify] 429 Retry-After=${retryAfter}s — failing fast`);
throw new Error(`RATE_LIMITED:${retryAfter}`);
}
if (!r.ok) throw new Error(`Spotify API Fehler ${r.status}: ${path}`);
return r.json();
}
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;
if (path) await new Promise(r => setTimeout(r, 300)); // pace multi-page requests
}
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 (>120s)')), 120000)
);
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' });
}
if (e.message.startsWith('RATE_LIMITED:')) {
const secs = parseInt(e.message.split(':')[1], 10) || 30;
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 || '',
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 <audio src> URLs and is unaffected by CORS.
app.all('/api/abs-proxy/*path', async (req, res) => {
const absBase = req.headers['x-abs-url'];
const absToken = req.headers['x-abs-token'];
if (!absBase || !absToken) { res.status(400).json({ error: 'Missing X-ABS-URL or X-ABS-Token' }); return; }
// Strip the proxy prefix to get the ABS path (e.g. /api/me, /api/libraries/…)
const absPath = req.path.replace(/^\/api\/abs-proxy/, '');
const qs = Object.keys(req.query).length ? '?' + new URLSearchParams(req.query).toString() : '';
const url = `${absBase}${absPath}${qs}`;
const fwdHeaders = { 'Authorization': `Bearer ${absToken}`, 'Content-Type': 'application/json' };
const opts = { method: req.method, headers: fwdHeaders };
if (req.method !== 'GET' && req.method !== 'HEAD' && req.body && Object.keys(req.body).length) {
opts.body = JSON.stringify(req.body);
}
try {
const upstream = await fetch(url, opts);
const text = await upstream.text();
res.status(upstream.status)
.setHeader('Content-Type', upstream.headers.get('Content-Type') || 'application/json')
.send(text);
} catch (e) {
res.status(502).json({ error: `ABS proxy error: ${e.message}` });
}
});
// ─── 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(` Download backend : ${CONFIG.downloadBackend}`);
if (CONFIG.downloadBackend !== 'ytdlp') {
console.log(` Squid quality : ${CONFIG.squidQuality}`);
}
console.log(` yt-dlp : ${ytdlpVersion}`);
console.log('');
});