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,143 @@
|
||||
const https = require('https');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
function sanitize(s) {
|
||||
return (s || 'Unknown').replace(/[\\/:*?"<>|]/g, '_').trim().slice(0, 100);
|
||||
}
|
||||
|
||||
function navidromeArtist(name) {
|
||||
if (!name) return name;
|
||||
return name
|
||||
.replace(/\s+feat\.?\s+/gi, ' / ')
|
||||
.replace(/\s+ft\.?\s+/gi, ' / ')
|
||||
.replace(/\s+featuring\s+/gi, ' / ')
|
||||
.replace(/,\s+/g, ' / ');
|
||||
}
|
||||
|
||||
// Download hi-res iTunes artwork (1000x1000) to cover.jpg, return path or null
|
||||
async function downloadArtwork(url, dir) {
|
||||
if (!url) return null;
|
||||
const dest = path.join(dir, 'cover.jpg');
|
||||
const hiRes = url.replace(/\d+x\d+bb\./, '1000x1000bb.');
|
||||
return new Promise(resolve => {
|
||||
const tryUrl = (u, fallback) => {
|
||||
const file = fs.createWriteStream(dest);
|
||||
https.get(u, res => {
|
||||
if (res.statusCode !== 200) {
|
||||
res.resume();
|
||||
file.close();
|
||||
try { fs.unlinkSync(dest); } catch {}
|
||||
if (fallback) tryUrl(fallback, null);
|
||||
else resolve(null);
|
||||
return;
|
||||
}
|
||||
res.pipe(file);
|
||||
file.on('finish', () => resolve(dest));
|
||||
file.on('error', () => resolve(null));
|
||||
}).on('error', () => {
|
||||
if (fallback) tryUrl(fallback, null);
|
||||
else resolve(null);
|
||||
});
|
||||
};
|
||||
tryUrl(hiRes, url !== hiRes ? url : null);
|
||||
});
|
||||
}
|
||||
|
||||
const AUDIO_EXTS = ['mp3', 'flac', 'opus', 'm4a', 'ogg', 'wav'];
|
||||
|
||||
function findExistingTrack(dir, trackPrefix, safeTitle) {
|
||||
return AUDIO_EXTS
|
||||
.map(ext => path.join(dir, `${trackPrefix}${safeTitle}.${ext}`))
|
||||
.find(f => fs.existsSync(f)) || null;
|
||||
}
|
||||
|
||||
// Fetch plain + synced lyrics from LRCLIB
|
||||
async function fetchLyrics(artist, title, album) {
|
||||
try {
|
||||
const p = new URLSearchParams({ artist_name: artist, track_name: title });
|
||||
if (album) p.set('album_name', album);
|
||||
const r = await fetch(`https://lrclib.net/api/get?${p}`, {
|
||||
headers: { 'User-Agent': 'NovaSonic/1.0' },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
const d = await r.json();
|
||||
return { plain: d.plainLyrics || null, synced: d.syncedLyrics || null };
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// iTunes track lookup — fills in album, year, trackNum, totalTracks, genre when missing
|
||||
async function lookupItunesTrackMeta(artist, title) {
|
||||
try {
|
||||
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(artist + ' ' + title)}&media=music&entity=musicTrack&limit=5&country=us&explicit=Yes`;
|
||||
const results = await new Promise((resolve, reject) => {
|
||||
https.get(url, res => {
|
||||
let d = ''; res.on('data', c => d += c);
|
||||
res.on('end', () => { try { resolve(JSON.parse(d).results || []); } catch { reject(new Error('parse')); } });
|
||||
}).on('error', reject);
|
||||
});
|
||||
const norm = s => s?.toLowerCase().trim() || '';
|
||||
const match = results.find(t =>
|
||||
norm(t.trackName) === norm(title) &&
|
||||
norm(t.artistName).split(' ')[0] === norm(artist).split(' ')[0]
|
||||
);
|
||||
if (!match) return {};
|
||||
return {
|
||||
album: match.collectionName || '',
|
||||
year: match.releaseDate ? new Date(match.releaseDate).getFullYear() : undefined,
|
||||
trackNum: match.trackNumber || undefined,
|
||||
totalTracks: match.trackCount || undefined,
|
||||
discNum: match.discNumber || undefined,
|
||||
discTotal: match.discCount || undefined,
|
||||
genre: match.primaryGenreName || undefined,
|
||||
artworkUrl: (match.artworkUrl100 || '').replace('100x100bb', '1000x1000bb'),
|
||||
durationMs: match.trackTimeMillis || undefined,
|
||||
};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
// Fetch individual tracks for an iTunes collectionId
|
||||
async function fetchItunesTracks(collectionId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `https://itunes.apple.com/lookup?id=${collectionId}&entity=song&explicit=Yes`;
|
||||
https.get(url, res => {
|
||||
let data = '';
|
||||
res.on('data', d => data += d);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
resolve((parsed.results || []).filter(r => r.wrapperType === 'track'));
|
||||
} catch { reject(new Error('iTunes API parse error')); }
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// Search iTunes for album tracks by artist + album name (fallback when no collectionId)
|
||||
async function searchItunesAlbumTracks(artist, album) {
|
||||
return new Promise(resolve => {
|
||||
const q = encodeURIComponent(`${artist} ${album}`);
|
||||
https.get(`https://itunes.apple.com/search?term=${q}&media=music&entity=album&limit=5&country=us&explicit=Yes`, res => {
|
||||
let d = '';
|
||||
res.on('data', c => d += c);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const results = JSON.parse(d).results || [];
|
||||
const match = results.find(r =>
|
||||
r.collectionName?.toLowerCase().includes(album.toLowerCase()) ||
|
||||
r.artistName?.toLowerCase().includes(artist.toLowerCase())
|
||||
);
|
||||
if (match) fetchItunesTracks(match.collectionId).then(resolve).catch(() => resolve([]));
|
||||
else resolve([]);
|
||||
} catch { resolve([]); }
|
||||
});
|
||||
}).on('error', () => resolve([]));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sanitize, navidromeArtist,
|
||||
downloadArtwork, AUDIO_EXTS, findExistingTrack,
|
||||
fetchLyrics, lookupItunesTrackMeta, fetchItunesTracks, searchItunesAlbumTracks,
|
||||
};
|
||||
Reference in new issue
Block a user