Two-part fix for "[Unknown Album]" vs "Singles" mismatch that broke the
metadata editor (File not found):
- Download: always write an album tag, defaulting to "Singles" so it matches
the on-disk folder (album || 'Singles'). No-album tracks no longer show as
"[Unknown Album]" while living in a "Singles" folder. (Future downloads.)
- Editor resolve: Subsonic getSong returns a *virtual* tag-built path (e.g.
"[Unknown Album]/Title.mp3" with raw "|"), not the physical file ("Singles/
Artist - Title_YTPMV.mp3"). Add findExistingTrack as a final fallback, and
make its normalizer map the sanitized chars (/ \ : * ? " < > | and _) to a
space so a title tag matches the sanitized filename. (Fixes existing tracks.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
243 lines
11 KiB
JavaScript
243 lines
11 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'];
|
||
|
||
// 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;
|
||
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,
|
||
};
|