From f1bdd811cae35b6f777e8af04bec3fcceff04c37 Mon Sep 17 00:00:00 2001 From: Honey Date: Thu, 18 Jun 2026 20:59:30 +0200 Subject: [PATCH] =?UTF-8?q?chore:=20remove=20squid.wtf=20(Qobuz)=20backend?= =?UTF-8?q?=20=E2=80=94=20yt-dlp=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 12 +- CHANGELOG.md | 3 + README.md | 17 +-- backend/lib/config.js | 5 +- backend/lib/downloader.js | 21 +-- backend/lib/squid.js | 292 ------------------------------------ backend/novasonic-server.js | 5 - docker-compose.yml | 3 +- 8 files changed, 15 insertions(+), 343 deletions(-) delete mode 100644 backend/lib/squid.js diff --git a/.env.example b/.env.example index 742fc07..5688e84 100644 --- a/.env.example +++ b/.env.example @@ -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/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f228fc6..fe54048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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") diff --git a/README.md b/README.md index 8f6b560..9d35372 100644 --- a/README.md +++ b/README.md @@ -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) | --- diff --git a/backend/lib/config.js b/backend/lib/config.js index cb08d2d..d42948a 100644 --- a/backend/lib/config.js +++ b/backend/lib/config.js @@ -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 || '', }; diff --git a/backend/lib/downloader.js b/backend/lib/downloader.js index ec71aab..e40695f 100644 --- a/backend/lib/downloader.js +++ b/backend/lib/downloader.js @@ -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); } diff --git a/backend/lib/squid.js b/backend/lib/squid.js deleted file mode 100644 index 6e267b0..0000000 --- a/backend/lib/squid.js +++ /dev/null @@ -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=; ... - 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 }; diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index dde48df..80f3eed 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -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(''); }); diff --git a/docker-compose.yml b/docker-compose.yml index 6600052..13729cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 "