findExistingTrack normalized titles by stripping ALL bracketed content, so "Therapy (Remix)" collapsed to "Therapy" and was skipped because the base track existed. Keep brackets that contain a version marker (remix/live/acoustic/ instrumental/vip/flip/edit/mix/…) so distinct recordings are recognized as new; still strip noise brackets (feat. X, Official Video). Verified via Deezer that the remix track carries the marker in its title. Unit-tested both directions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
130 lines
5.5 KiB
JavaScript
130 lines
5.5 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 cover 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;
|
||
|
||
// Version markers that make a track a DISTINCT recording — brackets containing these must NOT be
|
||
// stripped during duplicate detection, or a remix/live/acoustic/instrumental collapses onto the
|
||
// base track and gets falsely skipped (e.g. "Therapy (Remix)" was skipped because "Therapy" existed).
|
||
const _VERSION_KW = /\b(?:remix|live|acoustic|instrumental|version|vip|flip|bootleg|re-?work|reprise|unplugged|demo|mashup|cover|sped|slowed|nightcore|extended|edit|mix|dub)\b/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()
|
||
// Strip bracketed noise (feat. X, Official Video, Prod. by …) but KEEP version markers
|
||
// (Remix/Live/Acoustic/…) so distinct recordings aren't collapsed onto the base track.
|
||
.replace(/[\[((]([^\]))]*)[\]))]/g, (m, inner) => _VERSION_KW.test(inner) ? m : '')
|
||
// 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; }
|
||
}
|
||
|
||
// Track/album/artist metadata now comes from Deezer (backend/lib/deezer.js) — Apple's
|
||
// iTunes /search returns 403 from server IPs. See that module for lookupTrackMeta,
|
||
// fetchAlbumTracks, searchAlbumTracks and fetchArtistAlbums.
|
||
|
||
module.exports = {
|
||
sanitize, navidromeArtist,
|
||
downloadArtwork, findExistingTrack,
|
||
};
|