Files
NovaSonic/backend/novasonic-server.js
T

670 lines
28 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 and saves directly into your Navidrome music library
*
* Requirements:
* - Node.js 18+
* - yt-dlp installed (https://github.com/yt-dlp/yt-dlp)
* - ffmpeg installed
*/
const express = require('express');
const { spawn, execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const https = require('https');
// ─── CONFIG ───────────────────────────────────────────────────────────────────
const CONFIG = {
port: process.env.PORT || 3700,
musicPath: process.env.MUSIC_PATH || '/music',
ytdlpBin: process.env.YTDLP_BIN || 'yt-dlp',
audioFmt: process.env.AUDIO_FMT || 'mp3',
audioQuality: process.env.AUDIO_QUALITY || '0',
maxParallel: parseInt(process.env.MAX_PARALLEL || '2'),
};
// ─── INIT ─────────────────────────────────────────────────────────────────────
const app = express();
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization');
res.setHeader('Access-Control-Allow-Private-Network', 'true');
if (req.method === 'OPTIONS') { res.sendStatus(204); return; }
next();
});
app.use(express.json());
const jobs = new Map();
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 });
}
// ─── HELPERS ──────────────────────────────────────────────────────────────────
function sanitize(s) {
return (s || 'Unknown').replace(/[\\/:*?"<>|]/g, '_').trim().slice(0, 100);
}
// Convert "Artist feat. B" / "Artist ft. B" / "Artist featuring B" → "Artist / B"
// so Navidrome splits on "/" and lists each as a separate artist
function navidromeArtist(name) {
if (!name) return name;
return name
.replace(/\s+feat\.?\s+/gi, ' / ')
.replace(/\s+ft\.?\s+/gi, ' / ')
.replace(/\s+featuring\s+/gi, ' / ');
}
function updateJob(id, update) {
const job = jobs.get(id);
if (job) jobs.set(id, { ...job, ...update, updatedAt: Date.now() });
}
function log(jobId, msg) {
const j = jobs.get(jobId);
if (j) jobs.set(jobId, { ...j, log: [...(j.log || []), msg], message: msg });
console.log(`[${jobId}] ${msg}`);
}
// Download hi-res iTunes artwork (1000x1000) to cover.jpg, return path or null
async function downloadArtwork(url, dir) {
if (!url) return null;
const dest = path.join(dir, 'cover.jpg');
// Upgrade to 1000x1000 — iTunes URL pattern: ...100x100bb.jpg
const hiRes = url.replace(/\d+x\d+bb\./, '1000x1000bb.');
return new Promise(resolve => {
const tryUrl = (u, fallback) => {
const file = fs.createWriteStream(dest);
https.get(u, res => {
if (res.statusCode !== 200) {
res.resume();
file.close();
try { fs.unlinkSync(dest); } catch {}
if (fallback) tryUrl(fallback, null);
else resolve(null);
return;
}
res.pipe(file);
file.on('finish', () => resolve(dest));
file.on('error', () => resolve(null));
}).on('error', () => {
if (fallback) tryUrl(fallback, null);
else resolve(null);
});
};
tryUrl(hiRes, url !== hiRes ? url : null);
});
}
const AUDIO_EXTS = ['mp3', 'flac', 'opus', 'm4a', 'ogg', 'wav'];
function findExistingTrack(dir, trackPrefix, safeTitle) {
return AUDIO_EXTS
.map(ext => path.join(dir, `${trackPrefix}${safeTitle}.${ext}`))
.find(f => fs.existsSync(f)) || null;
}
// Fetch plain + synced lyrics from LRCLIB
async function fetchLyrics(artist, title, album) {
try {
const p = new URLSearchParams({ artist_name: artist, track_name: title });
if (album) p.set('album_name', album);
const r = await fetch(`https://lrclib.net/api/get?${p}`, {
headers: { 'User-Agent': 'NovaSonic/1.0' },
signal: AbortSignal.timeout(8000),
});
if (!r.ok) return null;
const d = await r.json();
return { plain: d.plainLyrics || null, synced: d.syncedLyrics || null };
} catch { return null; }
}
// iTunes track lookup — fills in album, year, trackNum, totalTracks, genre when missing
async function lookupItunesTrackMeta(artist, title) {
try {
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(artist + ' ' + title)}&media=music&entity=musicTrack&limit=5&country=us&explicit=Yes`;
const results = await new Promise((resolve, reject) => {
https.get(url, 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 norm = s => s?.toLowerCase().trim() || '';
const match = results.find(t =>
norm(t.trackName) === norm(title) &&
norm(t.artistName).split(' ')[0] === norm(artist).split(' ')[0]
) || results[0];
if (!match) return {};
return {
album: match.collectionName || '',
year: match.releaseDate ? new Date(match.releaseDate).getFullYear() : undefined,
trackNum: match.trackNumber || undefined,
totalTracks: match.trackCount || undefined,
discNum: match.discNumber || undefined,
discTotal: match.discCount || undefined,
genre: match.primaryGenreName || undefined,
artworkUrl: (match.artworkUrl100 || '').replace('100x100bb', '1000x1000bb'),
};
} catch { return {}; }
}
// Download a single track: yt-dlp audio + ffmpeg clean metadata + iTunes cover embedded
async function downloadTrack({ jobId, artist, albumArtist, title, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, outputDir }) {
const mainArtist = albumArtist || artist;
const safeArtist = sanitize(mainArtist);
const safeAlbum = sanitize(album || 'Singles');
const dir = path.join(outputDir || CONFIG.musicPath, safeArtist, safeAlbum);
fs.mkdirSync(dir, { recursive: true });
const safeTitle = sanitize(title);
const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
const existing = findExistingTrack(dir, trackPrefix, safeTitle);
if (existing) {
log(jobId, `⏭ Bereits vorhanden: ${path.basename(existing)} — übersprungen`);
return { success: true, dir, skipped: true };
}
const outputTemplate = path.join(dir, `${trackPrefix}${safeTitle}.%(ext)s`);
const query = `${mainArtist} - ${title} official audio`;
log(jobId, `🔍 Suche: "${query}"`);
// Step 1: yt-dlp — audio only, no thumbnail (we embed iTunes artwork ourselves)
await new Promise((resolve, reject) => {
const args = [
`ytsearch1:${query}`,
'--extract-audio',
'--audio-format', CONFIG.audioFmt,
'--audio-quality', CONFIG.audioQuality,
'-o', outputTemplate,
'--no-playlist',
'--playlist-items', '1',
'--no-warnings',
'--newline',
];
const proc = spawn(CONFIG.ytdlpBin, args, { env: { ...process.env, PYTHONUNBUFFERED: '1' } });
let stderr = '', lastPct = '';
proc.stdout.on('data', d => {
d.toString().split('\n').filter(l => l.trim()).forEach(line => {
if (line.includes('%') && line === lastPct) return;
if (line.includes('%')) lastPct = line;
log(jobId, line.trim());
});
});
proc.stderr.on('data', d => {
stderr += d.toString();
d.toString().split('\n')
.filter(l => l.trim() && !l.startsWith('[debug]'))
.forEach(l => log(jobId, l.trim()));
});
proc.on('error', reject);
proc.on('close', code =>
code === 0 ? resolve() : reject(new Error(`yt-dlp code ${code}: ${stderr.slice(-300)}`))
);
});
// Step 2: find the audio file yt-dlp wrote (newest audio file in dir)
const audioFile = fs.readdirSync(dir)
.filter(f => AUDIO_EXTS.some(e => f.endsWith(`.${e}`)))
.map(f => path.join(dir, f))
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0];
if (!audioFile) {
log(jobId, '⚠️ Keine Audio-Datei gefunden');
return { success: true, dir };
}
// Step 3: download artwork + fetch lyrics in parallel
const [coverPath, lyricsData] = await Promise.all([
downloadArtwork(artworkUrl, dir),
fetchLyrics(artist, title, album),
]);
const hasCover = !!(coverPath && fs.existsSync(coverPath));
if (lyricsData?.synced) {
const lrcPath = path.join(dir, `${trackPrefix}${safeTitle}.lrc`);
fs.writeFileSync(lrcPath, lyricsData.synced, 'utf8');
log(jobId, '🎵 Synced lyrics gefunden');
} else if (lyricsData?.plain) {
log(jobId, '📝 Lyrics gefunden');
}
log(jobId, `🏷 Metadaten + Cover → ${path.basename(audioFile)}`);
// Step 4: ffmpeg — strip old tags, write clean ID3, embed hi-res artwork
const fileExt = path.extname(audioFile);
const tmpFile = audioFile.slice(0, -fileExt.length) + '.metatmp' + fileExt;
await new Promise(resolve => {
const ffArgs = ['-i', audioFile];
if (hasCover) ffArgs.push('-i', coverPath);
// Map: audio from input 0, artwork from input 1 (if available)
ffArgs.push('-map', '0:a');
if (hasCover) ffArgs.push('-map', '1:v');
ffArgs.push(
'-c', 'copy',
'-map_metadata', '-1', // strip all existing tags (removes blurry YT thumbnail)
'-id3v2_version', '3',
'-metadata', `title=${title}`,
'-metadata', `artist=${navidromeArtist(artist)}`,
'-metadata', `album_artist=${mainArtist}`,
);
if (album) ffArgs.push('-metadata', `album=${album}`);
if (year) ffArgs.push('-metadata', `date=${String(year)}`);
if (trackNum) ffArgs.push('-metadata', `track=${totalTracks ? `${trackNum}/${totalTracks}` : String(trackNum)}`);
if (totalTracks) ffArgs.push('-metadata', `totaltracks=${String(totalTracks)}`);
if (genre) ffArgs.push('-metadata', `genre=${genre}`);
if (discNum) ffArgs.push('-metadata', `disc=${discTotal ? `${discNum}/${discTotal}` : String(discNum)}`);
if (compilation) ffArgs.push('-metadata', `compilation=1`);
if (explicit) ffArgs.push('-metadata', 'ITUNESADVISORY=1');
if (lyricsData?.plain) ffArgs.push('-metadata', `lyrics=${lyricsData.plain}`);
if (hasCover) {
ffArgs.push(
'-metadata:s:v', 'title=Album cover',
'-metadata:s:v', 'comment=Cover (front)',
);
}
ffArgs.push(tmpFile, '-y');
const ff = spawn('ffmpeg', ffArgs);
let ffErr = '';
ff.stderr.on('data', d => ffErr += d.toString());
ff.on('error', () => resolve());
ff.on('close', code => {
if (code === 0 && fs.existsSync(tmpFile)) {
fs.renameSync(tmpFile, audioFile);
log(jobId, `✅ Fertig${hasCover ? ' (Cover 1000x1000 eingebettet)' : ''}`);
} else {
try { fs.unlinkSync(tmpFile); } catch {}
log(jobId, `⚠️ ffmpeg code ${code}`);
if (ffErr) log(jobId, ffErr.slice(-200));
}
resolve();
});
});
return { success: true, dir };
}
// Download all tracks of an album sequentially
async function downloadAlbumTracks({ jobId, artist, albumTitle, tracks, artworkUrl }) {
const total = tracks.length;
log(jobId, `📀 Album "${albumTitle}" — ${total} Tracks`);
let done = 0, skipped = 0;
for (const track of tracks) {
try {
const result = await downloadTrack({
jobId,
artist: track.artistName || artist, // track artist (may include feat.)
albumArtist: artist, // always the main artist → consistent grouping
title: track.trackName || track.title,
album: albumTitle,
year: track.releaseDate ? new Date(track.releaseDate).getFullYear() : undefined,
trackNum: track.trackNumber || undefined,
totalTracks: total,
discNum: track.discNumber || undefined,
discTotal: track.discCount || undefined,
compilation: track.collectionType === 'Compilation' || undefined,
genre: track.primaryGenreName || undefined,
artworkUrl: artworkUrl || track.artworkUrl100,
explicit: track.trackExplicitness === 'explicit',
outputDir: CONFIG.musicPath,
});
if (result?.skipped) skipped++; else done++;
updateJob(jobId, { progress: Math.round(((done + skipped) / total) * 90) });
} catch (e) {
log(jobId, `⚠️ Fehler bei "${track.trackName || track.title}": ${e.message}`);
}
}
return { done, skipped };
}
// Search iTunes for album tracks by artist + album name (fallback when no collectionId)
async function searchItunesAlbumTracks(artist, album) {
return new Promise(resolve => {
const q = encodeURIComponent(`${artist} ${album}`);
https.get(`https://itunes.apple.com/search?term=${q}&media=music&entity=album&limit=5&country=us&explicit=Yes`, res => {
let d = '';
res.on('data', c => d += c);
res.on('end', () => {
try {
const results = JSON.parse(d).results || [];
const match = results.find(r =>
r.collectionName?.toLowerCase().includes(album.toLowerCase()) ||
r.artistName?.toLowerCase().includes(artist.toLowerCase())
);
if (match) fetchItunesTracks(match.collectionId).then(resolve).catch(() => resolve([]));
else resolve([]);
} catch { resolve([]); }
});
}).on('error', () => resolve([]));
});
}
// Fetch individual tracks for an iTunes collectionId
async function fetchItunesTracks(collectionId) {
return new Promise((resolve, reject) => {
const url = `https://itunes.apple.com/lookup?id=${collectionId}&entity=song&explicit=Yes`;
https.get(url, res => {
let data = '';
res.on('data', d => data += d);
res.on('end', () => {
try {
const parsed = JSON.parse(data);
resolve((parsed.results || []).filter(r => r.wrapperType === 'track'));
} catch { reject(new Error('iTunes API parse error')); }
});
}).on('error', reject);
});
}
// ─── 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 ──────────────────────────────────────────────────────────────────
// Scrapes Spotify's public embed pages — no token, no account, no Premium needed.
// Embed pages serve __NEXT_DATA__ JSON to anyone (used for website embeds worldwide).
function parseSpotifyUrl(url) {
// Handles locale prefixes: /intl-de/track/... and plain /track/...
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] };
}
async function fetchSpotifyEmbed(type, id) {
const r = await fetch(`https://open.spotify.com/embed/${type}/${id}`, {
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',
'Accept-Language': 'en-US,en;q=0.9',
},
signal: AbortSignal.timeout(12000),
});
if (!r.ok) throw new Error(`Spotify-Embed nicht erreichbar (${r.status})`);
const html = await r.text();
const m = html.match(/<script id="__NEXT_DATA__" type="application\/json">(.+?)<\/script>/s);
if (!m) throw new Error('Spotify-Seite nicht parsebar — Struktur geändert?');
const nd = JSON.parse(m[1]);
let entity = nd?.props?.pageProps?.state?.data?.entity
|| nd?.props?.pageProps?.data?.entity
|| nd?.props?.pageProps?.entity
|| nd?.props?.pageProps?.state?.data;
if (!entity) throw new Error('Keine Metadaten in Spotify-Embed gefunden');
// Unwrap GraphQL-style data wrapper: {data: {__typename, name, ...}}
if (entity.data && !entity.name && !entity.trackList) entity = entity.data;
return entity;
}
function _spArtwork(entity) {
// Handle albumOfTrack (newer GraphQL embed format) + legacy album/coverArt paths
const sources = entity?.albumOfTrack?.coverArt?.sources
|| entity?.album?.coverArt?.sources
|| entity?.coverArt?.sources
|| [];
if (sources.length) return sources.sort((a, b) => (b.width || 0) - (a.width || 0))[0].url;
return entity?.albumOfTrack?.images?.[0]?.url
|| entity?.album?.images?.[0]?.url
|| entity?.images?.[0]?.url
|| '';
}
function _spAllArtists(artistsRaw) {
if (!artistsRaw) return '';
// Handle both array format and GraphQL {items:[...]} format
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) {
// Unwrap optional {track: ...} wrapper (playlist item format)
let raw = t?.track || t;
// Handle GraphQL-style {data: {__typename, name, artists, ...}}
const d = raw?.data || raw;
const artist = _spAllArtists(d?.artists) || defaultAlbumArtist || '';
const art = _spArtwork(d) || defaultArt || '';
const albumObj = d?.albumOfTrack || d?.album || {};
const album = albumObj?.name || defaultAlbum || '';
const date = albumObj?.date?.isoString || albumObj?.release_date || '';
return {
trackName: d?.name || '',
artistName: artist,
collectionName: album,
artworkUrl100: art,
releaseDate: date,
trackNumber: d?.trackNumber || d?.track_number,
trackExplicitness: d?.contentRating?.label === 'EXPLICIT' || d?.explicit ? 'explicit' : 'notExplicit',
previewUrl: d?.previewUrl || d?.preview_url || null,
};
}
// ─── ROUTES ──────────────────────────────────────────────────────────────────
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', ytdlpVersion, musicPath: CONFIG.musicPath, audioFormat: CONFIG.audioFmt, activeJobs: activeDownloads, queueLength: downloadQueue.length });
});
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);
const entity = await fetchSpotifyEmbed(type, id);
const artist = _spArtistName(entity.artists);
const artUrl = _spArtwork(entity);
if (type === 'track') {
return res.json({
type: 'track',
name: entity.name,
artist,
artworkUrl: artUrl,
tracks: [_spTrackItem(entity, entity.album?.name, artUrl, artist)],
});
}
if (type === 'album') {
const trackList = entity.trackList || entity.tracks?.items || [];
return res.json({
type: 'album',
name: entity.name,
artist,
artworkUrl: artUrl,
tracks: trackList.map(t => _spTrackItem(t, entity.name, artUrl, artist)),
});
}
if (type === 'playlist') {
const trackList = entity.trackList || entity.content?.items || entity.tracks?.items || [];
const owner = entity.ownerV2?.data?.name || entity.owner?.displayName || entity.owner?.display_name || '';
const firstRaw = trackList[0];
console.log('[spotify/resolve] entity keys:', Object.keys(entity));
console.log('[spotify/resolve] trackList[0]:', JSON.stringify(firstRaw, null, 2).slice(0, 800));
return res.json({
type: 'playlist',
name: entity.name,
artist: owner,
artworkUrl: artUrl,
description: entity.description || '',
tracks: trackList.map(t => _spTrackItem(t, null, null, null)),
_debug: { entityKeys: Object.keys(entity), firstTrack: firstRaw },
});
}
res.status(400).json({ error: `Unbekannter Typ: ${type}` });
} catch(e) {
console.error('[spotify/resolve]', e.message);
res.status(500).json({ error: e.message });
}
});
app.post('/api/download', async (req, res) => {
const { type, artist, title, album, artworkUrl, collectionId, jobId: clientJobId, explicit } = 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') {
// Prefer Spotify metadata from tracks[0] when available (more accurate than iTunes lookup)
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 || undefined,
artworkUrl: spArt, explicit: sp.trackExplicitness === 'explicit' || !!explicit };
if (!meta.album) {
log(jobId, '📋 Suche Album-Metadaten von iTunes…');
const extra = await lookupItunesTrackMeta(artist, 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;
for (const track of plTracks) {
try {
const result = await downloadTrack({
jobId,
artist: track.artistName || artist || '',
albumArtist: track.artistName || artist || '',
title: track.trackName || '',
album: track.collectionName || '',
year: track.releaseDate ? new Date(track.releaseDate).getFullYear() : undefined,
trackNum: track.trackNumber || undefined,
genre: track.primaryGenreName || undefined,
artworkUrl: track.artworkUrl100 || artworkUrl || '',
explicit: track.trackExplicitness === 'explicit',
});
if (result?.skipped) skipped++; else done++;
updateJob(jobId, { progress: Math.round(((done + skipped) / plTracks.length) * 90) });
} catch(e) {
log(jobId, `⚠️ Fehler bei "${track.trackName || '?'}": ${e.message}`);
}
}
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));
});
// ─── 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(` yt-dlp : ${ytdlpVersion}`);
console.log('');
});