Files
NovaSonic/backend/novasonic-server.js
T
HoneyandClaude Sonnet 4.6 499179fd40 Show toast when download is skipped due to duplicate
Backend: set skipped:true on job when track already exists on disk.
Album downloads report "X geladen, Y bereits vorhanden" in the message.
Frontend: pollJob shows "⏭ bereits auf dem Server" instead of "✅ gespeichert"
when the job has skipped:true.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 14:06:45 +02:00

429 lines
17 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;
}
// 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, 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 hi-res iTunes artwork
const coverPath = await downloadArtwork(artworkUrl, dir);
const hasCover = !!(coverPath && fs.existsSync(coverPath));
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 (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,
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=de`, 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`;
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();
}
// ─── 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/download', async (req, res) => {
const { type, artist, title, album, artworkUrl, collectionId, jobId: clientJobId } = 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 result = await downloadTrack({ jobId, artist, title, album, artworkUrl });
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!` });
}
} 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('');
});