Files
NovaSonic/backend/novasonic-server.js
T

386 lines
18 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] };
}
// ─── 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 || '';
// If the frontend already enriched this track with iTunes data (collectionName +
// trackNumber + durationMs all present), skip the re-lookup to avoid a different
// iTunes result overriding the already-correct enriched metadata.
const alreadyEnriched = !!(track.collectionName && track.trackNumber && track.durationMs);
let itunes = {};
if (alreadyEnriched) {
log(jobId, `📋 Frontend-Metadaten: "${trackArtist} – ${trackTitle}" (${track.collectionName})`);
} else {
const searchArtist = trackArtist.split(',')[0].trim();
log(jobId, `📋 iTunes: "${trackArtist} – ${trackTitle}"`);
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 <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('');
});