Files
NovaSonic/backend/lib/metadata.js
T
HoneyandClaude Sonnet 4.6 fa4c954ab9 feat(0.9): romaji sort-tags only, robust dupe detection, Mittelweg no-duration, autofill all fields
- Japanese tracks stored with original names in folder/filename/tags; romaji only in sort tags
- findExistingTrack: case-insensitive, searches all album folders under artist, strips release suffix
- --max-filesize 30M + 2x duration cap on all yt-dlp calls
- iTunes collectionArtistName captured as album_artist; fixes compilation album splitting
- album_artist tag uses mainArtist (not rawArtist) so all tracks in one album are consistent
- No-duration cascade: steps 5-8 (title-only/no-filter) removed — fail cleanly like with-duration
- Autofill iTunes: fills title, artist, albumArtist, discNum (was album/genre/year/track/total only)
- Autofill MusicBrainz: fills albumArtist, discNum (was missing both)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 13:26:11 +02:00

240 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, '')
.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;
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);
// titleOnlyMatch (no artist check) is too permissive for Japanese titles/artists —
// common anime/OST titles exist on both JP and US stores under different artists.
// Only use it as a fallback when neither title nor artist contains Japanese characters.
const JP_RE = /[぀-ヿ㐀-䶿一-鿿]/;
const isJpSearch = JP_RE.test(normT) || JP_RE.test(normA);
let usMatch = strictMatch(usResults) || (!isJpSearch && 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 || '',
albumArtist: match.collectionArtistName || undefined,
trackName: match.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: (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,
};