1. JP genre mapping table in metadata.js — when only JP iTunes has the song (niche Japanese artists), map localized JP genre names (アニメ, ポップ, etc.) to English equivalents so files never get Japanese characters in genre tag. 2. Spotify playlist import (alreadyEnriched path) — was skipping iTunes lookup entirely, leaving genre empty. Now does a lightweight genre-only lookup. 3. Single track download with Spotify album — was skipping iTunes when album already known from Spotify. Now also fetches genre from iTunes when missing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
196 lines
8.3 KiB
JavaScript
196 lines
8.3 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+&\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, '')
|
||
// 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;
|
||
const searchArtist = 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 => (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);
|
||
|
||
// Fetch both in parallel — JP for correct Japanese titles/artwork, US for English genre names
|
||
const [jpResults, usResults] = await Promise.all([fetchItunes('jp'), fetchItunes('us')]);
|
||
let match = strictMatch(jpResults);
|
||
let usMatch = strictMatch(usResults) || titleOnlyMatch(usResults);
|
||
if (!match) match = usMatch;
|
||
if (!match) return {};
|
||
// JP genre names are localized (e.g. イージーリスニング); prefer US genre, map JP→EN as fallback
|
||
const rawGenre = (usMatch || match).primaryGenreName;
|
||
const genre = rawGenre ? toEnglishGenre(rawGenre) : undefined;
|
||
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: genre,
|
||
artworkUrl: (match.artworkUrl100 || '').replace('100x100bb', '1000x1000bb'),
|
||
durationMs: match.trackTimeMillis || undefined,
|
||
itunesArtistName: match.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=jp&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,
|
||
};
|