Files
NovaSonic/backend/novasonic-server.js
T
HoneyandClaude Sonnet 4.6 d2620519aa fix: replace AbortSignal.timeout with AbortController+setTimeout for reliable fetch timeouts
undici (Node.js native fetch) silently ignores AbortSignal.timeout when
TCP connect is blocked. Using AbortController driven by Node's setTimeout
guarantees the abort fires and the hanging request is cancelled within 10s.
Adds [spotify] token ok log to confirm when credentials work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:43:31 +02:00

484 lines
21 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] };
}
// AbortSignal.timeout() is unreliable in Node/undici when TCP connect is blocked.
// Use AbortController + setTimeout instead — always fires from Node's event loop.
function _fetchTimeout(url, opts, ms = 10000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), ms);
return fetch(url, { ...opts, signal: ctrl.signal }).finally(() => clearTimeout(timer));
}
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 <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('');
});