Files
NovaSonic/backend/novasonic-server.js
T
HoneyandClaude Sonnet 4.6 42658b3cbf fix(backend): improve yt-dlp match filters for accuracy
- artistFilterWord: use longest \w+ run instead of stripping special chars,
  fixes artists like N!GHT where stripping gave NGHT (not a substring)
- buildTitleKeyword: new fn extracts most distinctive title word, added as
  title *= filter to all cascade steps to block unrelated fallback downloads
- Mix version specificity: Radio/Extended/Club Mix titles also filter on the
  qualifier word so plain versions are rejected
- Switch uploader *= to artist *= so label-uploaded tracks (Monstercat etc.)
  match by music metadata artist tag, not channel name

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 12:46:20 +02:00

871 lines
38 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);
}
// Extract a plain word from an artist name for yt-dlp --match-filter uploader check.
// Uses longest contiguous \w+ run so special chars like "N!GHT" → "N" + "GHT" picks "GHT",
// but actually picks longest: "N!GHT" → runs ["N","GHT"] → "GHT" (3 chars).
// "☆NoAki☆" → "NoAki", "Chiru-san" → "Chiru", "A, B" → "A"
function artistFilterWord(name) {
const first = (name || '').split(/[,/•]/)[0].trim();
const runs = first.match(/\w+/g) || [];
const best = runs.filter(r => r.length >= 3).sort((a, b) => b.length - a.length)[0];
return best || '';
}
// Extract the most distinctive word from a track title to use as a title relevance filter.
// Prevents fallback steps from downloading completely unrelated tracks.
// Also preserves version qualifiers ("radio mix", "extended mix", "club mix", "vip mix") so
// "Eclipse - Radio Mix" filters for "eclipse" AND "radio" to avoid downloading the plain version.
function buildTitleKeyword(title) {
const VARIANT_WORDS = new Set(['sped','speedup','slowed','reverb','nightcore','karaoke','parody','remix','cover','acoustic','instrumental']);
const STOPWORDS = new Set(['the','a','an','and','or','of','in','on','at','to','for','feat','ft','with']);
const MIX_QUALIFIERS = ['radio', 'extended', 'club', 'vip', 'original', 'edit'];
const lower = (title || '').toLowerCase();
const words = lower
.replace(/[^\w\s]/g, ' ')
.split(/\s+/)
.filter(w => w.length >= 3 && !VARIANT_WORDS.has(w) && !STOPWORDS.has(w));
const main = words.sort((a, b) => b.length - a.length)[0] || '';
// If the title has a mix qualifier AND "mix", include the qualifier word too
const qualifier = lower.includes('mix') ? MIX_QUALIFIERS.find(q => lower.includes(q)) : null;
if (qualifier && qualifier !== main) return `${main} & title *= ${qualifier}`;
return main;
}
// Build yt-dlp --match-filter conditions to reject variant versions (speed up, slowed, remix…)
// unless the original track title already contains those keywords.
function buildVariantFilter(title) {
const t = (title || '').toLowerCase();
const RULES = [
{ key: 'sped', expr: 'title !*= sped' }, // "sped up"
{ key: 'speedup', expr: 'title !*= speedup' }, // "speedup"
{ key: 'slowed', expr: 'title !*= slowed' },
{ key: 'reverb', expr: 'title !*= reverb' },
{ key: 'nightcore', expr: 'title !*= nightcore' },
{ key: 'karaoke', expr: 'title !*= karaoke' },
{ key: 'parody', expr: 'title !*= parody' },
{ key: 'remix', expr: 'title !*= remix' },
{ key: 'cover', expr: 'title !*= cover' },
{ key: 'acoustic', expr: 'title !*= acoustic' },
{ key: 'instrumental', expr: 'title !*= instrumental' },
];
const parts = RULES.filter(r => !t.includes(r.key)).map(r => r.expr);
return parts.length ? parts.join(' & ') : '';
}
// Convert multi-artist strings to " / " separator so Navidrome splits correctly.
// Handles: "feat." / "ft." / "featuring" and comma-separated artists from Spotify/iTunes.
function navidromeArtist(name) {
if (!name) return name;
return name
.replace(/\s+feat\.?\s+/gi, ' / ')
.replace(/\s+ft\.?\s+/gi, ' / ')
.replace(/\s+featuring\s+/gi, ' / ')
.replace(/,\s+/g, ' / '); // "Artist A, Artist B" → "Artist A / Artist B"
}
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]
);
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'),
durationMs: match.trackTimeMillis || undefined,
};
} 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, durationMs }) {
const mainArtist = albumArtist || artist;
const safeArtist = sanitize(mainArtist.split(',')[0].trim());
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}`;
// Step 1: yt-dlp — audio only, no thumbnail (we embed iTunes artwork ourselves)
const baseArgs = [
'--extract-audio',
'--audio-format', CONFIG.audioFmt,
'--audio-quality', CONFIG.audioQuality,
'-o', outputTemplate,
'--no-playlist',
'--no-warnings',
'--newline',
];
// Filtered searches scan top 5 results and stop at first match.
// Unfiltered fallback only needs item 1 (no filter to skip past).
const PI_FILTERED = ['--playlist-items', '1-5', '--max-downloads', '1'];
const PI_UNFILTERED = ['--playlist-items', '1'];
const spawnYtdlp = (args) => new Promise((resolve, reject) => {
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 =>
// 101 = --max-downloads reached (not an error — file was downloaded successfully)
(code === 0 || code === 101) ? resolve() : reject(new Error(`yt-dlp code ${code}: ${stderr.slice(-300)}`))
);
});
const ytmUrl = `https://music.youtube.com/search?q=${encodeURIComponent(query)}`;
// Snapshot files already in dir before we start — so hasAudio() only detects NEW files
// from the current download attempt, not pre-existing files from parallel jobs.
let snapBefore = new Set(fs.readdirSync(dir));
const resetSnap = () => { snapBefore = new Set(fs.readdirSync(dir)); };
const hasAudio = () => fs.readdirSync(dir).some(
f => !snapBefore.has(f) && AUDIO_EXTS.some(e => f.endsWith(`.${e}`))
);
const tryYtdlp = async (args) => { resetSnap(); await spawnYtdlp(args); };
const uploaderWord = artistFilterWord(mainArtist);
const variantFilter = buildVariantFilter(title);
const titleKeyword = buildTitleKeyword(title);
const uploaderPart = uploaderWord ? ` & artist *= ${uploaderWord}` : '';
const variantPart = variantFilter ? ` & ${variantFilter}` : '';
const titleKwPart = titleKeyword ? ` & title *= ${titleKeyword}` : '';
// Build a --match-filter arg from active parts (duration, artist, variant, title keyword).
// !is_live is always included. titleKw always included to avoid totally unrelated downloads.
const mf = (dur, artist, variant) => {
const durPart = dur ? `duration > ${dur.min} & duration < ${dur.max} & ` : '';
return ['--match-filter',
`${durPart}!is_live${titleKwPart}${artist ? uploaderPart : ''}${variant ? variantPart : ''}`];
};
if (durationMs) {
const durationSec = Math.round(durationMs / 1000);
const margin = Math.max(15, Math.round(durationSec * 0.15)); // ±15 % (min 15 s)
const dur = { min: Math.max(30, durationSec - margin), max: durationSec + margin };
log(jobId, `🔍 Suche YT Music (~${durationSec}s ±${margin}s): "${query}"`);
// 1. YTM — Dauer + Artist + Varianten-Filter (Top 5, erster Treffer)
await tryYtdlp([ytmUrl, ...PI_FILTERED, ...mf(dur, true, true), ...baseArgs]);
if (!hasAudio()) {
// 2. YT — Dauer + Artist + Varianten-Filter
log(jobId, '⚠️ Kein YTM-Treffer — Fallback zu YouTube…');
await tryYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(dur, true, true), ...baseArgs]);
}
if (!hasAudio()) {
// 3. YTM — Dauer + Varianten-Filter (kein Artist)
log(jobId, '⚠️ Kein Treffer mit Artist-Filter — ohne Artist…');
await tryYtdlp([ytmUrl, ...PI_FILTERED, ...mf(dur, false, true), ...baseArgs]);
}
if (!hasAudio()) {
// 4. YT — Dauer + Varianten-Filter
await tryYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(dur, false, true), ...baseArgs]);
}
if (!hasAudio()) {
// 5. YTM — nur Varianten-Filter (kein Dauer-Check)
log(jobId, '⚠️ Kein Treffer im Dauerbereich — suche ohne Dauerbeschränkung…');
await tryYtdlp([ytmUrl, ...PI_FILTERED, ...mf(null, false, true), ...baseArgs]);
}
if (!hasAudio()) {
// 6. YT — nur Varianten-Filter
await tryYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, false, true), ...baseArgs]);
}
if (!hasAudio()) {
// 7+8. Letzter Ausweg: komplett ungefiltert
log(jobId, '⚠️ Letzter Versuch: keine Filter…');
await tryYtdlp([ytmUrl, ...PI_UNFILTERED, ...baseArgs]);
}
if (!hasAudio()) {
await tryYtdlp([`ytsearch1:${query}`, ...PI_UNFILTERED, ...baseArgs]);
}
} else {
log(jobId, `🔍 Suche YT Music: "${query}"`);
// Ohne Dauer: Artist + Varianten → ohne Artist → ohne alles
await tryYtdlp([ytmUrl, ...PI_FILTERED, ...mf(null, true, true), ...baseArgs]);
if (!hasAudio()) {
await tryYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, true, true), ...baseArgs]);
}
if (!hasAudio()) {
log(jobId, '⚠️ Kein Treffer mit Artist — ohne Artist-Filter…');
await tryYtdlp([ytmUrl, ...PI_FILTERED, ...mf(null, false, true), ...baseArgs]);
}
if (!hasAudio()) {
await tryYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, false, true), ...baseArgs]);
}
if (!hasAudio()) {
log(jobId, '⚠️ Letzter Versuch: keine Filter…');
await tryYtdlp([ytmUrl, ...PI_UNFILTERED, ...baseArgs]);
}
if (!hasAudio()) {
await tryYtdlp([`ytsearch1:${query}`, ...PI_UNFILTERED, ...baseArgs]);
}
}
// 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,
durationMs: track.trackTimeMillis || undefined,
});
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 (old playlist format)
let raw = t?.track || t;
// Handle GraphQL-style {data: {__typename, ...}}
const d = raw?.data || raw;
// Name: new embed uses "title", old/track embed uses "name"
const trackName = d?.name || d?.title || '';
// Artists: array format [{name}] or [{profile:{name}}], GraphQL {items:[...]},
// or flat string in "subtitle" (new playlist embed: "Myrne, Aviella")
let artist = '';
if (d?.artists) {
artist = _spAllArtists(d.artists);
} else if (typeof d?.subtitle === 'string' && d.subtitle) {
artist = d.subtitle; // already comma-separated, matches iTunes/Spotify standard
}
artist = artist || 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,
artistName: artist,
collectionName: album,
artworkUrl100: art,
releaseDate: date,
trackNumber: d?.trackNumber || d?.track_number,
trackExplicitness: d?.contentRating?.label === 'EXPLICIT' || d?.isExplicit || d?.explicit ? 'explicit' : 'notExplicit',
previewUrl: d?.previewUrl || d?.preview_url || d?.audioPreview?.url || null,
durationMs: d?.duration_ms || d?.durationMs || 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, 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);
// "subtitle" is used by the flat embed format as artist/owner string
const artist = _spArtistName(entity.artists) || (typeof entity.subtitle === 'string' ? entity.subtitle : '');
const artUrl = _spArtwork(entity);
if (type === 'track') {
return res.json({
type: 'track',
name: entity.name || entity.title || '',
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 || entity.title || '',
artist,
artworkUrl: artUrl,
tracks: trackList.map(t => _spTrackItem(t, entity.name || entity.title, artUrl, artist)),
});
}
if (type === 'playlist') {
const trackList = entity.trackList || entity.content?.items || entity.tracks?.items || [];
const owner = entity.authors?.[0]?.name
|| entity.ownerV2?.data?.name
|| entity.owner?.displayName
|| entity.owner?.display_name
|| (typeof entity.subtitle === 'string' ? entity.subtitle : '');
return res.json({
type: 'playlist',
name: entity.name || entity.title || '',
artist: owner,
artworkUrl: artUrl,
description: entity.description || '',
// no defaultArt for playlist tracks — iTunes lazy-loader will fill correct per-track art;
// passing playlist cover here would cause it to be embedded in downloads for unresolved tracks
tracks: trackList.map(t => _spTrackItem(t, null, null, null)),
});
}
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,
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') {
// 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 || 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) {
// Use first artist only for iTunes search (multi-artist strings like "Kløn, Renzyx" confuse iTunes)
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;
for (const track of plTracks) {
try {
const trackTitle = track.trackName || '';
// subtitle gives "Artist1, Artist2" — use first for iTunes search, keep full for tags
const trackArtist = track.artistName || artist || '';
const searchArtist = trackArtist.split(',')[0].trim();
// Always look up iTunes for correct album, artwork, year (Spotify flat format has none)
log(jobId, `📋 iTunes: "${trackArtist} – ${trackTitle}"`);
const itunes = await lookupItunesTrackMeta(searchArtist, trackTitle);
const result = await 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,
});
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('');
});