- Remove unused spotifyClientId/spotifyClientSecret from config - Remove AUDIO_EXTS from metadata exports (internal-only) - Remove tryYtdlp wrapper in ytdlp.js (direct spawnYtdlp calls) - Remove _spArtistName wrapper in server (direct _spAllArtists calls) - Fix stale comment: primary resolver is SpotipyFree, not curl_cffi Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
144 lines
5.2 KiB
JavaScript
144 lines
5.2 KiB
JavaScript
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, findExistingTrack,
|
|
fetchLyrics, lookupItunesTrackMeta, fetchItunesTracks, searchItunesAlbumTracks,
|
|
};
|