- 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>
292 lines
11 KiB
JavaScript
292 lines
11 KiB
JavaScript
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 };
|