- New 'Spotify Importer' page under Entdecken (sidebar + mobile nav) - Backend: POST /api/spotify/resolve — resolves track/album/playlist URLs via Spotify API - Backend: new 'playlist' download type accepting pre-resolved track list (no iTunes re-fetch) - Frontend: paste Spotify link → preview with cover, type badge, track list - Track: single download or full album via iTunes fallback - Album: download all tracks with Spotify metadata - Playlist: download all tracks + optional 'Als Navidrome-Playlist speichern' checkbox - Navidrome playlist creation: searches library by title/artist after download, creates playlist - Spotify Client ID + Secret stored in app settings (setup screen + settings modal) - iTunes metadata tags kept for all downloaded files (existing pipeline unchanged) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
611 lines
27 KiB
JavaScript
611 lines
27 KiB
JavaScript
/**
|
||
* 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 ──────────────────────────────────────────────────────────────────
|
||
let _spTok = null, _spTokExp = 0, _spCachedId = '';
|
||
|
||
async function getSpotifyToken(clientId, clientSecret) {
|
||
if (_spTok && clientId === _spCachedId && Date.now() < _spTokExp - 30000) return _spTok;
|
||
const creds = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
||
const r = await fetch('https://accounts.spotify.com/api/token', {
|
||
method: 'POST',
|
||
headers: { Authorization: `Basic ${creds}`, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'grant_type=client_credentials',
|
||
signal: AbortSignal.timeout(8000),
|
||
});
|
||
if (!r.ok) throw new Error(`Spotify Auth fehlgeschlagen (${r.status}) — Client ID/Secret prüfen`);
|
||
const d = await r.json();
|
||
_spTok = d.access_token; _spTokExp = Date.now() + d.expires_in * 1000; _spCachedId = clientId;
|
||
return _spTok;
|
||
}
|
||
|
||
function parseSpotifyUrl(url) {
|
||
const m = url.match(/open\.spotify\.com\/(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.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, clientId, clientSecret } = req.body;
|
||
if (!url) return res.status(400).json({ error: 'url required' });
|
||
if (!clientId || !clientSecret) return res.status(400).json({ error: 'Spotify credentials fehlen (clientId + clientSecret in Einstellungen eintragen)' });
|
||
try {
|
||
const { type, id } = parseSpotifyUrl(url);
|
||
const tok = await getSpotifyToken(clientId, clientSecret);
|
||
const spH = { Authorization: `Bearer ${tok}` };
|
||
const spFetch = (u) => fetch(u, { headers: spH, signal: AbortSignal.timeout(10000) });
|
||
|
||
if (type === 'track') {
|
||
const r = await spFetch(`https://api.spotify.com/v1/tracks/${id}`);
|
||
if (!r.ok) throw new Error(`Spotify API ${r.status}`);
|
||
const d = await r.json();
|
||
return res.json({ type: 'track', name: d.name, artist: d.artists?.[0]?.name || '', artworkUrl: d.album?.images?.[0]?.url || '',
|
||
tracks: [{ trackName: d.name, artistName: d.artists?.[0]?.name || '', collectionName: d.album?.name || '',
|
||
artworkUrl100: d.album?.images?.[0]?.url || '', releaseDate: d.album?.release_date || '',
|
||
trackNumber: d.track_number, trackExplicitness: d.explicit ? 'explicit' : 'notExplicit', previewUrl: d.preview_url || null }] });
|
||
}
|
||
|
||
if (type === 'album') {
|
||
const r = await spFetch(`https://api.spotify.com/v1/albums/${id}`);
|
||
if (!r.ok) throw new Error(`Spotify API ${r.status}`);
|
||
const d = await r.json();
|
||
const art = d.images?.[0]?.url || '';
|
||
return res.json({ type: 'album', name: d.name, artist: d.artists?.[0]?.name || '', artworkUrl: art,
|
||
tracks: (d.tracks?.items || []).map(t => ({ trackName: t.name, artistName: t.artists?.[0]?.name || d.artists?.[0]?.name || '',
|
||
collectionName: d.name, artworkUrl100: art, releaseDate: d.release_date || '',
|
||
trackNumber: t.track_number, trackExplicitness: t.explicit ? 'explicit' : 'notExplicit', previewUrl: t.preview_url || null })) });
|
||
}
|
||
|
||
if (type === 'playlist') {
|
||
const r = await spFetch(`https://api.spotify.com/v1/playlists/${id}?fields=name,description,images,owner,tracks.total`);
|
||
if (!r.ok) throw new Error(`Spotify API ${r.status}`);
|
||
const pl = await r.json();
|
||
const tracks = [];
|
||
let nextUrl = `https://api.spotify.com/v1/playlists/${id}/tracks?limit=100&fields=next,items(is_local,track(name,artists,album,track_number,explicit,preview_url))`;
|
||
while (nextUrl) {
|
||
const tr = await spFetch(nextUrl);
|
||
if (!tr.ok) break;
|
||
const td = await tr.json();
|
||
for (const item of (td.items || [])) {
|
||
if (!item?.track || item.is_local) continue;
|
||
tracks.push({ trackName: item.track.name, artistName: item.track.artists?.[0]?.name || '',
|
||
collectionName: item.track.album?.name || '', artworkUrl100: item.track.album?.images?.[0]?.url || '',
|
||
releaseDate: item.track.album?.release_date || '', trackNumber: item.track.track_number,
|
||
trackExplicitness: item.track.explicit ? 'explicit' : 'notExplicit', previewUrl: item.track.preview_url || null });
|
||
}
|
||
nextUrl = td.next || null;
|
||
}
|
||
return res.json({ type: 'playlist', name: pl.name, artist: pl.owner?.display_name || '',
|
||
artworkUrl: pl.images?.[0]?.url || '', description: pl.description || '', tracks });
|
||
}
|
||
|
||
res.status(400).json({ error: `Unbekannter Typ: ${type}` });
|
||
} catch(e) {
|
||
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') {
|
||
let meta = { album, year: undefined, trackNum: undefined, totalTracks: undefined, discNum: undefined, discTotal: undefined, genre: undefined };
|
||
if (!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 = { ...extra, artworkUrl: artworkUrl || extra.artworkUrl || '' };
|
||
}
|
||
const result = await downloadTrack({ jobId, artist, title, artworkUrl: artworkUrl || meta.artworkUrl || '', explicit: !!explicit, ...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('');
|
||
});
|