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:
1 parent
2d71fdce6a
commit
ac8d26bf9d
9 files changed
+858
-557
No files matched your search
@@ -0,0 +1,291 @@
|
||||
const crypto = require('crypto');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
const CONFIG = require('./config');
|
||||
const { log } = require('./jobs');
|
||||
const {
|
||||
sanitize, navidromeArtist,
|
||||
downloadArtwork, fetchLyrics, findExistingTrack,
|
||||
} = require('./metadata');
|
||||
const { embedMetadata } = require('./ytdlp');
|
||||
|
||||
const SQUID_BASE = 'https://qobuz.squid.wtf';
|
||||
|
||||
// Qobuz quality codes: mp3=5 (320kbps), flac=6 (16/44), hi-res=7 (24/96)
|
||||
const QUALITY_CODE = { mp3: '5', flac: '6', 'hi-res': '7' };
|
||||
|
||||
function squidExt() {
|
||||
return CONFIG.squidQuality === 'mp3' ? 'mp3' : 'flac';
|
||||
}
|
||||
|
||||
// ─── ALTCHA cookie cache ─────────────────────────────────────────────────────
|
||||
let _captchaCookie = null;
|
||||
let _captchaAt = 0;
|
||||
const CAPTCHA_TTL = 27 * 60 * 1000; // refresh 1 min before server's 28-min expiry
|
||||
|
||||
// Iterative SHA-256 PoW solver — yields every 1000 iterations to stay non-blocking
|
||||
async function solveAltcha(challenge) {
|
||||
const { nonce, salt, cost, keyLength, keyPrefix } = challenge;
|
||||
const saltBuf = Buffer.from(salt, 'hex');
|
||||
const nonceBuf = Buffer.from(nonce, 'hex');
|
||||
const prefixBuf = Buffer.from(keyPrefix, 'hex');
|
||||
const startMs = Date.now();
|
||||
const BATCH = 1000;
|
||||
|
||||
for (let base = 0; base < 1_000_000; base += BATCH) {
|
||||
for (let i = 0; i < BATCH; i++) {
|
||||
const counter = base + i;
|
||||
const ctrBuf = Buffer.allocUnsafe(4);
|
||||
ctrBuf.writeUInt32BE(counter);
|
||||
// password = nonce || counter_be32 ; initial = salt || password
|
||||
const initial = Buffer.concat([saltBuf, nonceBuf, ctrBuf]);
|
||||
let derived = crypto.createHash('sha256').update(initial).digest().slice(0, keyLength);
|
||||
for (let c = 0; c < cost; c++) {
|
||||
derived = crypto.createHash('sha256').update(derived).digest().slice(0, keyLength);
|
||||
}
|
||||
if (derived.slice(0, prefixBuf.length).equals(prefixBuf)) {
|
||||
return { counter, derivedKey: derived.toString('hex'), time: Date.now() - startMs };
|
||||
}
|
||||
}
|
||||
// Yield to event loop between batches
|
||||
await new Promise(r => setImmediate(r));
|
||||
}
|
||||
throw new Error('ALTCHA: no solution found within 1M iterations');
|
||||
}
|
||||
|
||||
function httpsGet(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const mod = url.startsWith('https') ? https : http;
|
||||
mod.get(url, { headers: { 'User-Agent': 'NovaSonic/1.0' } }, res => {
|
||||
let d = '';
|
||||
res.on('data', c => d += c);
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: d }));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function httpsPost(url, body, extraHeaders) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = JSON.stringify(body);
|
||||
const parsed = new URL(url);
|
||||
const opts = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || 443,
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
'User-Agent': 'NovaSonic/1.0',
|
||||
...extraHeaders,
|
||||
},
|
||||
};
|
||||
const req = https.request(opts, res => {
|
||||
let d = '';
|
||||
res.on('data', c => d += c);
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: d }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function getAltchaCookie() {
|
||||
if (_captchaCookie && Date.now() - _captchaAt < CAPTCHA_TTL) return _captchaCookie;
|
||||
|
||||
// 1. Fetch challenge
|
||||
const chRes = await httpsGet(`${SQUID_BASE}/api/altcha/challenge`);
|
||||
if (chRes.status !== 200) throw new Error(`ALTCHA challenge HTTP ${chRes.status}`);
|
||||
const challenge = JSON.parse(chRes.body);
|
||||
|
||||
// 2. Solve PoW
|
||||
const solution = await solveAltcha(challenge);
|
||||
|
||||
// 3. Verify — server sets captcha_verified_at cookie
|
||||
const payload = Buffer.from(JSON.stringify({ challenge, solution })).toString('base64');
|
||||
const vRes = await httpsPost(`${SQUID_BASE}/api/altcha/verify`, { payload });
|
||||
if (vRes.status !== 200) throw new Error(`ALTCHA verify HTTP ${vRes.status}`);
|
||||
|
||||
// Parse Set-Cookie: captcha_verified_at=<value>; ...
|
||||
const setCookie = [vRes.headers['set-cookie']].flat().filter(Boolean);
|
||||
const cookiePair = setCookie.map(c => c.split(';')[0]).find(c => c.startsWith('captcha_verified_at='));
|
||||
if (!cookiePair) throw new Error('ALTCHA verify: no cookie in response');
|
||||
|
||||
_captchaCookie = cookiePair;
|
||||
_captchaAt = Date.now();
|
||||
return _captchaCookie;
|
||||
}
|
||||
|
||||
async function squidApiGet(endpoint) {
|
||||
const cookie = await getAltchaCookie();
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `${SQUID_BASE}${endpoint}`;
|
||||
const parsed = new URL(url);
|
||||
const opts = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || 443,
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Cookie': cookie,
|
||||
'Token-Country': 'US',
|
||||
'User-Agent': 'NovaSonic/1.0',
|
||||
},
|
||||
};
|
||||
const req = https.request(opts, res => {
|
||||
let d = '';
|
||||
res.on('data', c => d += c);
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: d }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Search Qobuz via squid.wtf, return best matching track object or null
|
||||
async function squidSearch(artist, title, durationMs) {
|
||||
const query = encodeURIComponent(`${artist} ${title}`);
|
||||
const res = await squidApiGet(`/api/get-music?q=${query}&offset=0`);
|
||||
if (res.status !== 200) throw new Error(`squid search HTTP ${res.status}`);
|
||||
|
||||
const json = JSON.parse(res.body);
|
||||
// Handle both {data:{tracks:{items:[...]}}} and {tracks:{items:[...]}} wrappers
|
||||
const items = json?.data?.tracks?.items ?? json?.tracks?.items ?? [];
|
||||
if (!items.length) return null;
|
||||
|
||||
const norm = s => (s || '').toLowerCase().trim();
|
||||
const normT = norm(title);
|
||||
const normA = norm(artist).split(/[,\s]/)[0];
|
||||
|
||||
// Score candidates: prefer exact title match + matching artist + duration proximity
|
||||
const scored = items.map(t => {
|
||||
const tTitle = norm(t.title);
|
||||
const tArtist = norm(t.performer?.name || '');
|
||||
const tDuration = (t.duration || 0) * 1000; // Qobuz uses seconds
|
||||
|
||||
let score = 0;
|
||||
if (tTitle === normT) score += 100;
|
||||
else if (tTitle.includes(normT)) score += 50;
|
||||
if (tArtist.startsWith(normA)) score += 30;
|
||||
if (durationMs && tDuration) {
|
||||
const diff = Math.abs(tDuration - durationMs);
|
||||
if (diff < 3000) score += 20; // within 3 s
|
||||
else if (diff < 10000) score += 10; // within 10 s
|
||||
else if (diff > 30000) score -= 20; // way off
|
||||
}
|
||||
return { track: t, score };
|
||||
});
|
||||
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
const best = scored[0];
|
||||
// Reject if score is too low (no reasonable match)
|
||||
if (best.score < 30) return null;
|
||||
return best.track;
|
||||
}
|
||||
|
||||
// Stream a URL to destPath (handles redirects via Location header)
|
||||
async function downloadUrlToFile(url, destPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tryFetch = (u, hops) => {
|
||||
if (hops > 5) { reject(new Error('Too many redirects')); return; }
|
||||
const mod = u.startsWith('https') ? https : http;
|
||||
const parsed = new URL(u);
|
||||
const opts = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (u.startsWith('https') ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: 'GET',
|
||||
headers: { 'User-Agent': 'NovaSonic/1.0' },
|
||||
};
|
||||
const req = mod.request(opts, res => {
|
||||
if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307) {
|
||||
res.resume();
|
||||
tryFetch(res.headers.location, hops + 1);
|
||||
return;
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
res.resume();
|
||||
reject(new Error(`Download HTTP ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
const file = fs.createWriteStream(destPath);
|
||||
res.pipe(file);
|
||||
file.on('finish', () => file.close(resolve));
|
||||
file.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
};
|
||||
tryFetch(url, 0);
|
||||
});
|
||||
}
|
||||
|
||||
// Download a single track via squid.wtf / Qobuz.
|
||||
// Returns { success: true, dir, skipped? } on success, null if track not found on Qobuz.
|
||||
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 ext = squidExt();
|
||||
|
||||
const existing = findExistingTrack(dir, trackPrefix, safeTitle);
|
||||
if (existing) {
|
||||
log(jobId, `⏭ Bereits vorhanden: ${path.basename(existing)} — übersprungen`);
|
||||
return { success: true, dir, skipped: true };
|
||||
}
|
||||
|
||||
log(jobId, `🦑 squid.wtf: suche "${artist} – ${title}"`);
|
||||
|
||||
// Solve ALTCHA if needed (cached for 27 min)
|
||||
await getAltchaCookie();
|
||||
|
||||
// Find track on Qobuz
|
||||
const searchArtist = mainArtist.replace(/[^\p{L}\p{N}\s,&\-]/gu, '').trim();
|
||||
const track = await squidSearch(searchArtist, title, durationMs);
|
||||
if (!track) {
|
||||
log(jobId, `⚠️ squid.wtf: "${title}" nicht auf Qobuz gefunden`);
|
||||
return null; // signals caller to fall back to yt-dlp
|
||||
}
|
||||
|
||||
// Get download URL
|
||||
const quality = QUALITY_CODE[CONFIG.squidQuality] || '5';
|
||||
const dlRes = await squidApiGet(`/api/download-music?track_id=${track.id}&quality=${quality}`);
|
||||
if (dlRes.status !== 200) throw new Error(`squid download-music HTTP ${dlRes.status}`);
|
||||
|
||||
const dlJson = JSON.parse(dlRes.body);
|
||||
const streamUrl = dlJson?.data?.url ?? dlJson?.url;
|
||||
if (!streamUrl) throw new Error('squid: no download URL in response');
|
||||
|
||||
// Download to a temp file, then embed metadata
|
||||
const audioFile = path.join(dir, `${trackPrefix}${safeTitle}.${ext}`);
|
||||
const tmpFile = audioFile + '.squidtmp';
|
||||
log(jobId, `⬇️ Lade herunter (Qobuz ${CONFIG.squidQuality.toUpperCase()})…`);
|
||||
try {
|
||||
await downloadUrlToFile(streamUrl, tmpFile);
|
||||
fs.renameSync(tmpFile, audioFile);
|
||||
} catch (e) {
|
||||
try { fs.unlinkSync(tmpFile); } catch {}
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Use Qobuz artwork if no better URL was passed in
|
||||
const finalArtwork = artworkUrl || track.album?.image?.large || track.album?.image?.small || '';
|
||||
|
||||
return embedMetadata({
|
||||
jobId, audioFile, dir, trackPrefix, safeTitle,
|
||||
artist, mainArtist, album, year, trackNum, totalTracks,
|
||||
discNum, discTotal, compilation, genre,
|
||||
artworkUrl: finalArtwork, explicit, title,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { downloadTrack };
|
||||
Reference in new issue
Block a user