chore: remove squid.wtf (Qobuz) backend — yt-dlp only

Deletes backend/lib/squid.js; strips all references from downloader.js,
config.js, novasonic-server.js, docker-compose.yml, .env.example, README,
and CHANGELOG. Default DOWNLOAD_BACKEND is now ytdlp.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-06-18 20:59:30 +02:00
1 parent 925c173c15
commit f1bdd811ca
8 files changed
+15 -343

No files matched your search

+1 -11
View File
@@ -11,17 +11,7 @@ AUDIO_FMT=mp3 # mp3 | flac | opus | best (used by yt-dlp)
AUDIO_QUALITY=0 # 0 = best quality (used by yt-dlp)
MAX_PARALLEL=2
# Download backend: ytdlp | squid | auto
# ytdlp - always use yt-dlp
# squid - always use squid.wtf (Qobuz)
# auto - try squid.wtf first, fall back to yt-dlp if not found (default)
DOWNLOAD_BACKEND=auto
# Quality for squid.wtf / Qobuz downloads
# mp3 - MP3 320 kbps
# flac - FLAC 16-bit / 44.1 kHz
# hi-res - FLAC 24-bit / 96 kHz
SQUID_QUALITY=mp3
DOWNLOAD_BACKEND=ytdlp
# Ticketmaster Events (optional — free API key)
# 1. Sign up at https://developer.ticketmaster.com/
+3
View File
@@ -62,6 +62,9 @@ Versioning: `MAJOR.MINOR.PATCH` — minor = new feature, patch = fix/improvement
- **Android** — Artist fast-scroll index bar: draggable letter strip on the right side of the Artists list with letter bubble while dragging
- **Web** — Playlist edit modal: "Cover entfernen" button removed
### Removed
- **Backend** — squid.wtf (Qobuz) download backend removed; downloads now exclusively use yt-dlp. `DOWNLOAD_BACKEND` and `SQUID_QUALITY` env vars are no longer read.
### Fixed
- **Android** — In-app notification bell: notifications were never delivered after navigating away from the Importer screen (poll ran in `viewModelScope`, cancelled on navigation); polling now uses `NotificationStore.startPoll()` with an app-lifetime scope
- **Android** — Home hero card: pool now matches web — union of `frequent(5) ∪ recentlyPlayed(5)` deduped, random pick; label is "MEISTGESPIELT" or "ZULETZT GEHÖRT" (was hardcoded "AKTUELL")
+6 -11
View File
@@ -37,8 +37,8 @@ Built with Vite + vanilla ES modules (web) and Jetpack Compose + Media3/ExoPlaye
- iTunes / Last.fm — charts, global search, 30-second previews, similar artists
- Song Importer — paste a Spotify, YouTube, YouTube Music, or SoundCloud link to download
### Downloads (yt-dlp backend)
- Download tracks, albums, or entire artists from YouTube Music / SoundCloud / Spotify
### Downloads
- Download tracks, albums, or entire artists from YouTube Music / SoundCloud / Spotify via yt-dlp
- iTunes metadata enrichment — album art, year, track number, genre
- Duplicate detection, standard folder/filename structure
@@ -50,7 +50,7 @@ Built with Vite + vanilla ES modules (web) and Jetpack Compose + Media3/ExoPlaye
- Create, rename, and delete playlists
- Add tracks via "Add to Playlist" context menu
- Remove tracks from a playlist (edit mode on Android; inline on Web)
- Cover image upload (Web)
- Cover image upload (Web + Android)
### Audiobookshelf Integration
- Browse libraries and audiobooks on both Web and Android
@@ -67,9 +67,10 @@ Built with Vite + vanilla ES modules (web) and Jetpack Compose + Media3/ExoPlaye
### App — Android (native Jetpack Compose)
- Media3 / ExoPlayer backend
- OS notification with playback controls (chapter name shown for audiobooks)
- OS notification with playback controls
- ABS audiobook notification: chapter name shown as title, book title as artist; extra buttons — Kapitel zurück | 30s zurück | Play/Pause | 30s vor | Kapitel vor (compact + expanded)
- Last played song shown in mini player bar on restart; tap to resume
- Full playlist management: create, rename, delete, remove tracks
- Full playlist management: create, rename (with description), delete, remove tracks, cover upload
---
@@ -112,7 +113,6 @@ NovaSonic/
│ ├── jobs.js # In-memory download job store
│ ├── metadata.js # iTunes lookup, artwork, filename helpers
│ ├── ytdlp.js # yt-dlp download + ffmpeg metadata embedding
│ ├── squid.js # Qobuz (squid.wtf) download backend
│ ├── downloader.js # Unified downloadTrack / downloadAlbumTracks
│ └── romaji.js # Japanese → romaji search index
├── android/ # Native Android app (Jetpack Compose + Media3)
@@ -162,9 +162,6 @@ AUDIO_FMT=mp3 # mp3 | flac | opus | best
AUDIO_QUALITY=0 # 0 = best (yt-dlp)
MAX_PARALLEL=2
DOWNLOAD_BACKEND=auto # ytdlp | squid | auto
SQUID_QUALITY=mp3 # mp3 | flac | hi-res
TICKETMASTER_API_KEY= # optional — free key from developer.ticketmaster.com
```
@@ -203,8 +200,6 @@ Frontend on port 8080, backend on port 3700. Route both through your reverse pro
| `AUDIO_FMT` | `mp3` | Download format (`mp3`, `flac`, `opus`, `best`) |
| `AUDIO_QUALITY` | `0` | yt-dlp audio quality (0 = best) |
| `MAX_PARALLEL` | `2` | Max concurrent download jobs |
| `DOWNLOAD_BACKEND` | `auto` | `ytdlp` / `squid` / `auto` |
| `SQUID_QUALITY` | `mp3` | Qobuz quality: `mp3`, `flac`, `hi-res` |
| `TICKETMASTER_API_KEY` | — | Free key from [developer.ticketmaster.com](https://developer.ticketmaster.com) |
---
+1 -4
View File
@@ -6,10 +6,7 @@ const CONFIG = {
audioFmt: process.env.AUDIO_FMT || 'mp3',
audioQuality: process.env.AUDIO_QUALITY || '0',
maxParallel: parseInt(process.env.MAX_PARALLEL || '2', 10),
// 'ytdlp' | 'squid' | 'auto' (squid first, yt-dlp fallback)
downloadBackend: (process.env.DOWNLOAD_BACKEND || 'auto').toLowerCase(),
// squid.wtf quality: 'mp3' | 'flac' | 'hi-res'
squidQuality: (process.env.SQUID_QUALITY || 'mp3').toLowerCase(),
downloadBackend: (process.env.DOWNLOAD_BACKEND || 'ytdlp').toLowerCase(),
ticketmasterKey: process.env.TICKETMASTER_API_KEY || '',
};
+3 -18
View File
@@ -1,24 +1,9 @@
const CONFIG = require('./config');
const { log, updateJob } = require('./jobs');
const ytdlp = require('./ytdlp');
const squid = require('./squid');
const ytdlp = require('./ytdlp');
const CONFIG = require('./config');
// Route a single track download to the correct backend, with optional fallback.
// Download a single track via yt-dlp.
async function downloadTrack(opts) {
const backend = CONFIG.downloadBackend;
if (backend === 'ytdlp') return ytdlp.downloadTrack(opts);
if (backend === 'squid') return squid.downloadTrack(opts);
// 'auto': squid first, yt-dlp as fallback
try {
const result = await squid.downloadTrack(opts);
if (result !== null) return result;
// null = not found on Qobuz — fall through to yt-dlp
log(opts.jobId, '⚠️ squid.wtf: nicht gefunden — Fallback zu yt-dlp');
} catch (e) {
log(opts.jobId, `⚠️ squid.wtf Fehler — Fallback zu yt-dlp: ${e.message}`);
}
return ytdlp.downloadTrack(opts);
}
-292
View File
@@ -1,292 +0,0 @@
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 { romajiForStorage } = require('./romaji');
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
// Standard ALTCHA PoW solver: find number such that SHA-256(salt + number) == challenge
async function solveAltcha(challenge) {
const { algorithm = 'SHA-256', challenge: target, salt, maxnumber = 1_000_000 } = challenge;
if (algorithm !== 'SHA-256') throw new Error(`ALTCHA: unsupported algorithm ${algorithm}`);
const startMs = Date.now();
for (let number = 0; number <= maxnumber; number++) {
const hash = crypto.createHash('sha256').update(salt + String(number)).digest('hex');
if (hash === target) {
return { number, time: Date.now() - startMs };
}
if (number % 10_000 === 0) await new Promise(r => setImmediate(r));
}
throw new Error('ALTCHA: no solution found within maxnumber');
}
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 — standard ALTCHA returns { algorithm, challenge, salt, signature, maxnumber }
const chRes = await httpsGet(`${SQUID_BASE}/api/altcha/challenge`);
if (chRes.status !== 200) throw new Error(`ALTCHA challenge HTTP ${chRes.status}: ${chRes.body.slice(0,200)}`);
const chBody = JSON.parse(chRes.body);
const challenge = chBody.parameters ?? chBody; // unwrap { parameters: {...} } if present
log('altcha', `🔐 ALTCHA challenge: alg=${challenge.algorithm} max=${challenge.maxnumber}`);
// 2. Solve PoW
const solution = await solveAltcha(challenge);
log('altcha', `🔐 ALTCHA gelöst in ${solution.time}ms (number=${solution.number})`);
// 3. Verify — standard ALTCHA payload: base64(JSON { algorithm, challenge, number, salt, signature })
const payloadObj = {
algorithm: challenge.algorithm || 'SHA-256',
challenge: challenge.challenge,
number: solution.number,
salt: challenge.salt,
signature: challenge.signature,
};
const payload = Buffer.from(JSON.stringify(payloadObj)).toString('base64');
const vRes = await httpsPost(`${SQUID_BASE}/api/altcha/verify`, { payload });
if (vRes.status !== 200) throw new Error(`ALTCHA verify HTTP ${vRes.status}: ${vRes.body.slice(0,200)}`);
// 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, rawArtist, albumArtist, title, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, outputDir, durationMs }) {
const mainArtist = albumArtist || artist;
// Use romaji for folder/filename — same as ytdlp path so paths are consistent
const mainArtistRomaji = romajiForStorage(mainArtist) || mainArtist;
const titleRomaji = romajiForStorage(title) || title;
const albumRomaji = album ? (romajiForStorage(album) || album) : null;
const safeArtist = sanitize(mainArtistRomaji.split(/[,;&]/)[0].trim());
const safeAlbum = sanitize(albumRomaji || 'Singles');
const dir = path.join(outputDir || CONFIG.musicPath, safeArtist, safeAlbum);
fs.mkdirSync(dir, { recursive: true });
const safeTitle = sanitize(titleRomaji);
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,
rawArtist, artist, mainArtist, album, year, trackNum, totalTracks,
discNum, discTotal, compilation, genre,
artworkUrl: finalArtwork, explicit, title,
});
}
module.exports = { downloadTrack };
-5
View File
@@ -386,7 +386,6 @@ app.get('/api/health', (req, res) => {
musicPath: CONFIG.musicPath,
audioFormat: CONFIG.audioFmt,
downloadBackend: CONFIG.downloadBackend,
squidQuality: CONFIG.squidQuality,
activeJobs: activeDownloads,
queueLength: downloadQueue.length,
spotify: { configured: true },
@@ -1428,10 +1427,6 @@ 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(` Download backend : ${CONFIG.downloadBackend}`);
if (CONFIG.downloadBackend !== 'ytdlp') {
console.log(` Squid quality : ${CONFIG.squidQuality}`);
}
console.log(` yt-dlp : ${ytdlpVersion}`);
console.log('');
});
+1 -2
View File
@@ -30,8 +30,7 @@ services:
- AUDIO_FMT=${AUDIO_FMT:-mp3}
- AUDIO_QUALITY=${AUDIO_QUALITY:-0}
- MAX_PARALLEL=${MAX_PARALLEL:-2}
- DOWNLOAD_BACKEND=${DOWNLOAD_BACKEND:-auto}
- SQUID_QUALITY=${SQUID_QUALITY:-mp3}
- DOWNLOAD_BACKEND=${DOWNLOAD_BACKEND:-ytdlp}
- TICKETMASTER_API_KEY=${TICKETMASTER_API_KEY}
command: >
sh -c "