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+&\s+/g, ';') .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, safeArtist) { // Check new format "{prefix}{artist} - {title}.ext" first, then old format "{prefix}{title}.ext" const names = safeArtist ? [`${trackPrefix}${safeArtist} - ${safeTitle}`, `${trackPrefix}${safeTitle}`] : [`${trackPrefix}${safeTitle}`]; return AUDIO_EXTS.flatMap(ext => names.map(n => path.join(dir, `${n}.${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; } } // Normalize a track title for comparison: unify variant suffixes so that // "don't rush - slowed" and "don't rush (slowed)" are treated as equal. function normalizeTitle(s) { return (s || '') .toLowerCase() .trim() // convert "title - variant" → "title (variant)" so both forms compare equal .replace(/\s+-\s+([^-]+)$/, ' ($1)') // strip all remaining parens/brackets content for the base comparison .replace(/[\[((][^\]))]*[\]))]/g, '') .trim(); } // iTunes track lookup — fills in album, year, trackNum, totalTracks, genre when missing async function lookupItunesTrackMeta(artist, title) { try { // Search with base title (strip variant suffix) for better iTunes hit rate — // e.g. "don't rush - slowed" → search "don't rush" so iTunes finds the single const baseTitle = title.replace(/\s*[-–]\s*\S[^-]*$/, '').trim() || title; const searchTitle = baseTitle !== title ? baseTitle : title; const url = `https://itunes.apple.com/search?term=${encodeURIComponent(artist + ' ' + searchTitle)}&media=music&entity=musicTrack&limit=10&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 normT = normalizeTitle(title); const normA = (artist || '').toLowerCase().trim(); // Split by space OR comma/semicolon/slash so "VXLLAIN, iGRES" → "vxllain" not "vxllain," const firstWord = s => (s || '').toLowerCase().split(/[\s,;/]/)[0]; const match = results.find(t => normalizeTitle(t.trackName) === normT && firstWord(t.artistName) === firstWord(normA) ); 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, findExistingTrack, lookupItunesTrackMeta, fetchItunesTracks, searchItunesAlbumTracks, };