- Remove unused spotifyClientId/spotifyClientSecret from config - Remove AUDIO_EXTS from metadata exports (internal-only) - Remove tryYtdlp wrapper in ytdlp.js (direct spawnYtdlp calls) - Remove _spArtistName wrapper in server (direct _spAllArtists calls) - Fix stale comment: primary resolver is SpotipyFree, not curl_cffi Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
642 lines
28 KiB
JavaScript
642 lines
28 KiB
JavaScript
/**
|
||
* 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
|
||
}
|
||
|
||
throw new Error('Spotify-Token nicht abrufbar (TOTP gescheitert)');
|
||
}
|
||
|
||
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 _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: 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`));
|
||
});
|
||
});
|
||
}
|
||
|
||
async function _resolveSpotify(type, id, originalUrl) {
|
||
// Primary: SpotipyFree (SpotAPI + tls_client Chrome fingerprint, Spotify internal endpoints)
|
||
try {
|
||
console.log('[spotify] trying spotdl resolver');
|
||
return await _resolveSpotifyViaSpotdl(originalUrl);
|
||
} catch (e) {
|
||
if (e.message.startsWith('RATE_LIMITED:')) throw e; // propagate, don't retry via TOTP
|
||
console.log('[spotify] spotdl failed:', e.message, '— falling back to TOTP');
|
||
}
|
||
|
||
// Fallback: TOTP anonymous token
|
||
const token = await fetchSpotifyToken();
|
||
|
||
if (type === 'track') {
|
||
const t = await _spApi(`/tracks/${id}`, token);
|
||
const artist = _spAllArtists(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 = _spAllArtists(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, 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;
|
||
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('');
|
||
});
|