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 cover 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']; // Strips release-type suffixes like "- Single", "- EP", "2024" from end of title/stem. const _RELEASE_SUFFIX_RE = /\s*[-–]\s*(?:(?:official\s+)?(?:music\s+)?(?:single|ep|lp|album|ost|soundtrack|remaster(?:ed)?|deluxe(?:\s+edition)?|bonus\s+tracks?|b-?sides?|version|edit)\b.*|(?:20|19)\d{2})\s*$/i; // Version markers that make a track a DISTINCT recording — brackets containing these must NOT be // stripped during duplicate detection, or a remix/live/acoustic/instrumental collapses onto the // base track and gets falsely skipped (e.g. "Therapy (Remix)" was skipped because "Therapy" existed). const _VERSION_KW = /\b(?:remix|live|acoustic|instrumental|version|vip|flip|bootleg|re-?work|reprise|unplugged|demo|mashup|cover|sped|slowed|nightcore|extended|edit|mix|dub)\b/i; // Robust duplicate detection: search case-insensitively across all album folders // under the artist folder matching firstArtist, compare normalized title ignoring // release suffixes and track prefixes. Handles DJ OKAWARI vs DJ Okawari, and // "recognize - Single.mp3" vs "recognize.mp3" (drift between runs). function findExistingTrack(musicRoot, firstArtist, cleanTitle) { const norm = s => (s || '').toLowerCase() // Strip bracketed noise (feat. X, Official Video, Prod. by …) but KEEP version markers // (Remix/Live/Acoustic/…) so distinct recordings aren't collapsed onto the base track. .replace(/[\[((]([^\]))]*)[\]))]/g, (m, inner) => _VERSION_KW.test(inner) ? m : '') // Filesystem-sanitized chars (/ \ : * ? " < > |) become "_" on disk — map those AND "_" // to a space so a title tag "Kwik Flip | YTPMV" matches the file "Kwik Flip _ YTPMV". .replace(/[/\\:*?"<>|_]/g, ' ') .replace(/[,.'!]/g, '') .replace(/\s+/g, ' ') .trim(); const normArtist = norm(firstArtist); const normTitle = norm(cleanTitle); // Accept both the raw cleanTitle and version without release suffix (e.g. "recognize" matches "recognize - Single") const titleVariants = new Set([normTitle, norm(cleanTitle.replace(_RELEASE_SUFFIX_RE, ''))]); try { for (const ad of fs.readdirSync(musicRoot, { withFileTypes: true })) { if (!ad.isDirectory() || norm(ad.name) !== normArtist) continue; const artistPath = path.join(musicRoot, ad.name); try { for (const abd of fs.readdirSync(artistPath, { withFileTypes: true })) { if (!abd.isDirectory()) continue; const albumPath = path.join(artistPath, abd.name); try { for (const f of fs.readdirSync(albumPath)) { const ext = path.extname(f).slice(1).toLowerCase(); if (!AUDIO_EXTS.includes(ext)) continue; // Strip extension + leading track prefix ("01 - ", "01-15 – " etc.) const stem = f.slice(0, -(ext.length + 1)).replace(/^\d[\d-]*\s*[-–—]\s*/, ''); // Stem may be "Artist - Title" or just "Title" const di = stem.indexOf(' - '); const rawTitle = di >= 0 ? stem.slice(di + 3) : stem; const normStemTitle = norm(rawTitle.replace(_RELEASE_SUFFIX_RE, '')); if (titleVariants.has(normStemTitle)) return path.join(albumPath, f); } } catch {} } } catch {} } } catch {} return 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; } } // Track/album/artist metadata now comes from Deezer (backend/lib/deezer.js) — Apple's // iTunes /search returns 403 from server IPs. See that module for lookupTrackMeta, // fetchAlbumTracks, searchAlbumTracks and fetchArtistAlbums. module.exports = { sanitize, navidromeArtist, downloadArtwork, findExistingTrack, };