Files
NovaSonic/backend/novasonic-server.js
T
HoneyandClaude Sonnet 4.6 b6edfcf14b Add NAVIDROME_URL autofill, remove Navidrome scan trigger
- Frontend reads window.NS_SERVER_URL from config.js (injected at container start via docker-entrypoint.sh) and pre-fills the setup screen
- Removed triggerNaviScan from frontend and backend (Navidrome auto-scans on file change)
- Removed NAVIDROME_USER/PASS from backend and compose

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:44:38 +02:00

385 lines
15 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
*
* Install & run:
* npm install express cors
* node novasonic-server.js
*
* Or with Docker - see docker-compose.yml
*/
const express = require('express');
const cors = require('cors');
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', // Navidrome music library folder
ytdlpBin: process.env.YTDLP_BIN || 'yt-dlp', // Path to yt-dlp binary
audioFmt: process.env.AUDIO_FMT || 'mp3', // mp3 | flac | opus | best
audioQuality: process.env.AUDIO_QUALITY || '0', // 0 = best
maxParallel: parseInt(process.env.MAX_PARALLEL || '2'),
};
// ─── INIT ─────────────────────────────────────────────────────────────────────
const app = express();
app.use(cors());
app.use(express.json());
// In-memory job store
const jobs = new Map();
// Check yt-dlp on startup
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! Install from https://github.com/yt-dlp/yt-dlp');
}
// Ensure music dir exists
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);
}
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 a single track via yt-dlp, then stamp metadata via a dedicated ffmpeg pass.
// We do NOT use --postprocessor-args for metadata because yt-dlp splits that string on
// spaces internally, so any metadata value with a space (e.g. album name) gets corrupted.
// Instead: yt-dlp downloads, then we call ffmpeg ourselves with properly separated args.
async function downloadTrack({ jobId, artist, title, album, year, trackNum, artworkUrl, outputDir }) {
const safeArtist = sanitize(artist);
const safeAlbum = sanitize(album || 'Singles');
const dir = path.join(outputDir || CONFIG.musicPath, safeArtist, safeAlbum);
fs.mkdirSync(dir, { recursive: true });
const safeTitle = sanitize(title);
const outputTemplate = path.join(dir, `${safeTitle}.%(ext)s`);
const query = `${artist} - ${title} official audio`;
log(jobId, `\uD83D\uDD0D Suche: "${query}"`);
// Step 1: yt-dlp – download + extract audio + embed YT thumbnail
await new Promise((resolve, reject) => {
const args = [
`ytsearch1:${query}`,
'--extract-audio',
'--audio-format', CONFIG.audioFmt,
'--audio-quality', CONFIG.audioQuality,
'-o', outputTemplate,
'--embed-thumbnail',
'--convert-thumbnails', 'jpg',
'--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 just wrote (newest audio file in dir)
const audioExts = ['mp3', 'flac', 'opus', 'm4a', 'ogg', 'wav'];
const audioFile = fs.readdirSync(dir)
.filter(f => audioExts.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, '\u26A0\uFE0F Keine Audio-Datei gefunden – \xFCberspringe Metadaten');
if (artworkUrl) downloadCoverArt(dir, artworkUrl);
return { success: true, dir };
}
log(jobId, `\uD83C\uDFF7 Schreibe Metadaten \u2192 ${path.basename(audioFile)}`);
// Step 3: ffmpeg – rewrite ID3 tags. Each -metadata is a separate arg so spaces are safe.
const fileExt = path.extname(audioFile);
const tmpFile = audioFile.slice(0, -fileExt.length) + '.metatmp' + fileExt;
await new Promise(resolve => {
const ffArgs = [
'-i', audioFile,
'-c', 'copy',
'-map_metadata', '0',
'-id3v2_version', '3',
'-metadata', `title=${title}`,
'-metadata', `artist=${artist}`,
'-metadata', `album_artist=${artist}`,
];
if (album) ffArgs.push('-metadata', `album=${album}`);
if (year) ffArgs.push('-metadata', `date=${String(year)}`);
if (trackNum) ffArgs.push('-metadata', `track=${String(trackNum)}`);
ffArgs.push(tmpFile, '-y');
const ff = spawn('ffmpeg', ffArgs);
let ffErr = '';
ff.stderr.on('data', d => ffErr += d.toString());
ff.on('error', () => resolve()); // ffmpeg not installed? skip gracefully
ff.on('close', code => {
if (code === 0 && fs.existsSync(tmpFile)) {
fs.renameSync(tmpFile, audioFile);
log(jobId, '\u2705 Metadaten geschrieben (title, artist, album, year, track)');
} else {
try { fs.unlinkSync(tmpFile); } catch {}
log(jobId, `\u26A0\uFE0F ffmpeg Metadaten-Schritt: code ${code}`);
if (ffErr) log(jobId, ffErr.slice(-200));
}
resolve();
});
});
// Step 4: save cover.jpg for Navidrome folder-art
if (artworkUrl) downloadCoverArt(dir, artworkUrl);
return { success: true, dir };
}
// Fallback: embed thumbnail via separate ffmpeg call (for formats where yt-dlp fails)
async function embedThumbnailFallback(dir, safeTitle, artworkUrl, jobId) {
if (!artworkUrl) return;
const coverPath = path.join(dir, 'cover.jpg');
if (!fs.existsSync(coverPath)) return;
const ext = CONFIG.audioFmt;
const files = fs.readdirSync(dir).filter(f => f.startsWith(safeTitle) && f.endsWith(`.${ext}`));
if (!files.length) return;
const audioFile = path.join(dir, files[0]);
const tmpFile = audioFile + '.tmp.' + ext;
return new Promise(resolve => {
const proc = spawn('ffmpeg', [
'-i', audioFile, '-i', coverPath,
'-map', '0', '-map', '1',
'-c', 'copy', '-id3v2_version', '3',
'-metadata:s:v', 'title=Album cover',
'-metadata:s:v', 'comment=Cover (front)',
tmpFile, '-y'
]);
proc.on('close', code => {
if (code === 0) {
fs.renameSync(tmpFile, audioFile);
log(jobId, '🖼 Cover eingebettet');
} else {
try { fs.unlinkSync(tmpFile); } catch {}
}
resolve();
});
proc.on('error', () => resolve());
});
}
// Download multiple tracks (album/artist)
async function downloadAlbumTracks({ jobId, artist, albumTitle, tracks, artworkUrl }) {
const total = tracks.length;
log(jobId, `📀 Album "${albumTitle}" – ${total} Tracks`);
let done = 0;
for (const track of tracks) {
try {
await downloadTrack({
jobId,
artist: track.artistName || artist,
title: track.trackName || track.title,
album: albumTitle,
year: track.releaseDate ? new Date(track.releaseDate).getFullYear() : undefined,
trackNum: track.trackNumber || undefined,
artworkUrl: artworkUrl || track.artworkUrl100,
outputDir: CONFIG.musicPath,
});
done++;
updateJob(jobId, { progress: Math.round((done / total) * 90) });
} catch (e) {
log(jobId, `⚠️ Fehler bei "${track.trackName}": ${e.message}`);
}
}
}
// Fetch iTunes album tracks
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);
});
}
// Save cover.jpg into folder for Navidrome folder-art detection
function downloadCoverArt(dir, url) {
if (!url) return;
try {
const dest = path.join(dir, 'cover.jpg');
if (fs.existsSync(dest)) return;
const file = fs.createWriteStream(dest);
const imgUrl = url.replace('100x100', '600x600').replace('60x60', '600x600');
https.get(imgUrl, res => res.pipe(file)).on('error', () => {});
} catch {}
}
// Active download 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 ──────────────────────────────────────────────────────────────────
// Health check
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
ytdlpVersion,
musicPath: CONFIG.musicPath,
audioFormat: CONFIG.audioFmt,
activeJobs: activeDownloads,
queueLength: downloadQueue.length,
});
});
// Submit download job
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 the actual work
enqueue(async () => {
updateJob(jobId, { status: 'running', progress: 5, message: 'Gestartet…' });
try {
if (type === 'track') {
await downloadTrack({ jobId, artist, title, album, artworkUrl });
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) {
await downloadAlbumTracks({ jobId, artist, albumTitle: album || title, tracks, artworkUrl });
} else {
// Fallback: download full album search
await downloadTrack({ jobId, artist, title: `${album || title} full album`, album, artworkUrl });
}
updateJob(jobId, { status: 'done', progress: 100, message: `Album "${album || title}" fertig!` });
} else if (type === 'artist') {
log(jobId, `🎤 Künstler-Download: ${artist}`);
// Search for artist's top albums via iTunes
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);
const total = artistAlbums.length || 1;
log(jobId, `📀 ${total} 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 / total) * 95) });
}
updateJob(jobId, { status: 'done', progress: 100, message: `Künstler "${artist}" vollständig heruntergeladen!` });
}
} catch (err) {
console.error(`[${jobId}] Error:`, err.message);
updateJob(jobId, { status: 'error', message: err.message });
}
});
});
// Job status
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);
});
// List all jobs
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('');
});