feat: split backend into lib/ modules + add squid.wtf (Qobuz) download backend

- backend/lib/config.js  — CONFIG from env (adds DOWNLOAD_BACKEND, SQUID_QUALITY)
- backend/lib/jobs.js    — jobs Map, updateJob(), log()
- backend/lib/metadata.js — iTunes, artwork, lyrics, sanitize helpers
- backend/lib/ytdlp.js   — yt-dlp download cascade + shared embedMetadata()
- backend/lib/squid.js   — ALTCHA PoW solver, Qobuz search/download via squid.wtf
- backend/lib/downloader.js — backend selector: ytdlp | squid | auto (squid-first + fallback)
- novasonic-server.js thinned to Express routes only, imports from lib/
- docker-compose: mount full backend/ dir, install deps outside read-only volume
- .env.example: document DOWNLOAD_BACKEND and SQUID_QUALITY

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-02 20:44:26 +02:00
1 parent 2d71fdce6a
commit ac8d26bf9d
9 files changed
+858 -557

No files matched your search

+276
View File
@@ -0,0 +1,276 @@
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const CONFIG = require('./config');
const { log } = require('./jobs');
const {
sanitize, navidromeArtist,
downloadArtwork, fetchLyrics, findExistingTrack,
} = require('./metadata');
// Extract a plain word from an artist name for yt-dlp --match-filter uploader check.
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 distinctive words from a track title for yt-dlp title ~= (?i) filters.
function buildTitleKeywords(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 strippedTitle = (title || '').replace(/\([^)]*\)/g, '').trim();
const words = strippedTitle
.replace(/[^\w\s]/g, ' ')
.split(/\s+/)
.filter(w => w.length >= 3 && !VARIANT_WORDS.has(w.toLowerCase()) && !STOPWORDS.has(w.toLowerCase()));
const top2 = words.sort((a, b) => b.length - a.length).slice(0, 2);
const toPattern = w => (w.length >= 4 && w.endsWith('s')) ? w + '?' : w;
const result = top2.map(toPattern);
const qualifier = lower.includes('mix') ? MIX_QUALIFIERS.find(q => lower.includes(q)) : null;
if (qualifier && !top2.some(w => w.toLowerCase() === qualifier)) result.push(qualifier);
return result;
}
// Build yt-dlp --match-filter conditions to reject variant versions.
function buildVariantFilter(title) {
const t = (title || '').toLowerCase();
const RULES = [
{ key: 'sped', expr: 'title !*= sped' },
{ key: 'speedup', expr: 'title !*= 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(' & ') : '';
}
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 searchArtist = mainArtist.replace(/[^\p{L}\p{N}\s,&\-]/gu, '').trim();
const query = `${searchArtist} - ${title}`;
const baseArgs = [
'--extract-audio',
'--audio-format', CONFIG.audioFmt,
'--audio-quality', CONFIG.audioQuality,
'-o', outputTemplate,
'--no-playlist',
'--no-warnings',
'--newline',
'--match-filter', 'webpage_url !*= /browse/',
];
const PI_FILTERED = ['--playlist-items', '1-5', '--max-downloads', '1'];
const PI_FILTERED_10 = ['--playlist-items', '1-10', '--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 =>
(code === 0 || code === 101) ? resolve() : reject(new Error(`yt-dlp code ${code}: ${stderr.slice(-300)}`))
);
});
const YTM_SONGS_PARAM = 'EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D';
const ytmUrl = `https://music.youtube.com/search?q=${encodeURIComponent(query)}&params=${YTM_SONGS_PARAM}`;
const expectedAudio = path.join(dir, `${trackPrefix}${safeTitle}.${CONFIG.audioFmt}`);
const hasAudio = () => fs.existsSync(expectedAudio);
const tryYtdlp = async (args) => { await spawnYtdlp(args); };
const variantFilter = buildVariantFilter(title);
const titleKws = buildTitleKeywords(title);
const variantPart = variantFilter ? ` & ${variantFilter}` : '';
const titleKwPart = titleKws.length ? titleKws.map(k => ` & title ~= (?i)${k}`).join('') : '';
const mf = (dur, variant) => {
const durPart = dur ? `duration > ${dur.min} & duration < ${dur.max} & ` : '';
return ['--match-filter',
`${durPart}!is_live${titleKwPart}${variant ? variantPart : ''}`];
};
if (durationMs) {
const durationSec = Math.round(durationMs / 1000);
const margin = Math.max(15, Math.round(durationSec * 0.15));
const dur = { min: Math.max(30, durationSec - margin), max: durationSec + margin };
log(jobId, `🔍 Suche YT Music (~${durationSec}s ±${margin}s): "${query}"`);
await tryYtdlp([ytmUrl, ...PI_FILTERED, ...mf(dur, true), ...baseArgs]);
if (!hasAudio()) {
log(jobId, '⚠️ Kein YTM-Treffer — Fallback zu YouTube…');
await tryYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(dur, true), ...baseArgs]);
}
if (!hasAudio()) {
log(jobId, '⚠️ Erweitere Suche auf top 10 mit Dauerbeschränkung…');
await tryYtdlp([ytmUrl, ...PI_FILTERED_10, ...mf(dur, true), ...baseArgs]);
}
if (!hasAudio()) {
await tryYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(dur, true), ...baseArgs]);
}
if (!hasAudio()) {
log(jobId, '⚠️ Kein Treffer im Dauerbereich — suche ohne Dauerbeschränkung…');
await tryYtdlp([ytmUrl, ...PI_FILTERED, ...mf(null, true), ...baseArgs]);
}
if (!hasAudio()) {
await tryYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, true), ...baseArgs]);
}
if (!hasAudio()) {
log(jobId, '⚠️ Letzter gefilterter Versuch…');
await tryYtdlp([ytmUrl, ...PI_FILTERED, ...mf(null, false), ...baseArgs]);
}
if (!hasAudio()) {
await tryYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, false), ...baseArgs]);
}
if (!hasAudio()) {
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}"`);
await tryYtdlp([ytmUrl, ...PI_FILTERED, ...mf(null, true), ...baseArgs]);
if (!hasAudio()) {
await tryYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, true), ...baseArgs]);
}
if (!hasAudio()) {
log(jobId, '⚠️ Letzter gefilterter Versuch…');
await tryYtdlp([ytmUrl, ...PI_FILTERED, ...mf(null, false), ...baseArgs]);
}
if (!hasAudio()) {
await tryYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, false), ...baseArgs]);
}
if (!hasAudio()) {
log(jobId, '⚠️ Letzter Versuch: keine Filter…');
await tryYtdlp([ytmUrl, ...PI_UNFILTERED, ...baseArgs]);
}
if (!hasAudio()) {
await tryYtdlp([`ytsearch1:${query}`, ...PI_UNFILTERED, ...baseArgs]);
}
}
const audioFile = hasAudio() ? expectedAudio : null;
if (!audioFile) {
log(jobId, '⚠️ Keine Audio-Datei gefunden');
return { success: true, dir };
}
return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title, lyricsArtist: artist });
}
// Shared ffmpeg metadata embedding — used by both yt-dlp and squid paths
async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title }) {
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)}`);
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);
ffArgs.push('-map', '0:a');
if (hasCover) ffArgs.push('-map', '1:v');
ffArgs.push(
'-c', 'copy',
'-map_metadata', '-1',
'-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);
if (hasCover) try { fs.unlinkSync(coverPath); } catch {}
const base = path.basename(audioFile, path.extname(audioFile));
for (const ext of ['webm', 'webm.part', 'm4a', 'opus']) {
try { fs.unlinkSync(path.join(dir, `${base}.${ext}`)); } catch {}
}
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 };
}
module.exports = { downloadTrack, embedMetadata };