Files
NovaSonic/backend/lib/squid.js
T
HoneyandClaude Sonnet 4.6 8f8c38fcf0 fix: word-boundary variant filter; reattempt ALTCHA verify payload structure
- ytdlp.js: use \b word boundaries in all variant filter regexes so that
  partial matches like "nightcoreals" containing "nightcore" no longer
  falsely reject official videos; also fixes sped regression (!~= \bsped\b
  instead of the too-narrow "sped up" pattern)
- squid.js: restructure verify payload to mirror challenge response layout
  { parameters, solution: { number, derivedKey }, signature }

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 01:48:29 +02:00

298 lines
11 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.
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 — response may wrap params in a "parameters" key
const chRes = await httpsGet(`${SQUID_BASE}/api/altcha/challenge`);
if (chRes.status !== 200) throw new Error(`ALTCHA challenge HTTP ${chRes.status}`);
const chBody = JSON.parse(chRes.body);
const challenge = chBody.parameters ?? chBody; // unwrap { parameters: {...} } if present
// 2. Solve PoW
const solution = await solveAltcha(challenge);
// 3. Verify — mirror the challenge response structure: { parameters, signature } + solution fields
const verifyBody = {
parameters: challenge,
solution: { number: solution.counter, derivedKey: solution.derivedKey },
signature: chBody.signature,
};
const payload = Buffer.from(JSON.stringify(verifyBody)).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 };