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']; // 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; // 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() .replace(/[\[((][^\]))]*[\]))]/g, '') // 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; } } // 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, '') // strip punctuation so "Mr. Lawrence" == "Mr Lawrence" and comma variants match .replace(/[,.'!?]/g, '') .replace(/\s+/g, ' ') .trim(); } // iTunes JP store returns localized genre names — map them to English equivalents const JP_GENRE_MAP = { 'アニメ': 'Anime', 'イージーリスニング': 'Easy Listening', 'ポップ': 'Pop', 'J-Pop': 'J-Pop', 'ロック': 'Rock', 'クラシック': 'Classical', 'ジャズ': 'Jazz', 'エレクトロニック': 'Electronic', 'R&B/ソウル': 'R&B/Soul', 'ヒップホップ/ラップ': 'Hip-Hop/Rap', 'オルタナティブ': 'Alternative', 'サウンドトラック': 'Soundtrack', 'ボーカル': 'Vocal', 'インストゥルメンタル': 'Instrumental', 'ワールド': 'World', 'フォーク': 'Folk', 'カントリー': 'Country', 'レゲエ': 'Reggae', 'ソウル': 'Soul', 'ブルース': 'Blues', 'メタル': 'Metal', 'パンク': 'Punk', 'ダンス': 'Dance', 'コメディ': 'Comedy', 'スピーチ': 'Speech', 'キッズ/ファミリー': 'Kids/Family', }; function toEnglishGenre(g) { return JP_GENRE_MAP[g] || g; } // 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; // Strip leading aesthetic punctuation many artists use (".diedlonely", "†", "✝", "_x") // so the iTunes search actually surfaces them instead of a same-titled track by someone else. const stripLeadSym = s => (s || '').replace(/^[^\p{L}\p{N}]+/u, '').trim(); const searchArtist = stripLeadSym(artist.split(/[;,&]/)[0].trim()); const fetchItunes = (country) => new Promise((resolve, reject) => { const url = `https://itunes.apple.com/search?term=${encodeURIComponent(searchArtist + ' ' + searchTitle)}&media=music&entity=musicTrack&limit=10&country=${country}&explicit=Yes`; 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 = (searchArtist || '').toLowerCase().trim(); // Split by space OR comma/semicolon/slash so "VXLLAIN, iGRES" → "vxllain" not "vxllain," const firstWord = s => stripLeadSym((s || '').toLowerCase()).split(/[\s,;/]/)[0]; const strictMatch = results => results.find(t => normalizeTitle(t.trackName) === normT && firstWord(t.artistName) === firstWord(normA) ); const titleOnlyMatch = results => results.find(t => normalizeTitle(t.trackName) === normT); // US store only — one consistent naming source (English/romaji) across search + download, so the // same artist always resolves the same way instead of "sometimes Japanese, sometimes romaji". A // single request instead of JP+US also halves the download's iTunes calls (helps the rate limit). const usResults = await fetchItunes('us'); const JP_RE = /[぀-ヿ㐀-䶿一-鿿]/; const isJpSearch = JP_RE.test(normT) || JP_RE.test(normA); // titleOnlyMatch (no artist check) is too permissive when the title is Japanese — common anime/ // OST titles exist under different artists — so only use it as a fallback for non-Japanese titles. const match = strictMatch(usResults) || (!isJpSearch && titleOnlyMatch(usResults)); if (!match) return {}; const naming = match; const rawGenre = match.primaryGenreName; const genre = rawGenre ? toEnglishGenre(rawGenre) : undefined; return { album: naming.collectionName || '', albumArtist: naming.collectionArtistName || undefined, trackName: naming.trackName || undefined, 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: genre, artworkUrl: (naming.artworkUrl100 || match.artworkUrl100 || '').replace('100x100bb', '1000x1000bb'), durationMs: match.trackTimeMillis || undefined, itunesArtistName: naming.artistName || 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, };