findExistingTrack split the filename at the first ' - ' assuming 'Artist - Title',
so a title with internal dashes ('Kingdom Hearts 2 - Dearly Beloved - Main Theme')
or a garbage/absent artist prefix ('01-01 - …') was mis-parsed → the duplicate went
undetected and got re-downloaded (3-4x). Now match the query title against BOTH the
full stem AND the part after the first ' - ', so titles with dashes and prefix-only
filenames are recognized. Normal 'Artist - Title' files still match; no false
positives; remix-vs-base distinction preserved. Unit-tested on the exact KH case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
137 lines
6.1 KiB
JavaScript
137 lines
6.1 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*/, '');
|
||
// Candidate titles from the filename. The old code only used the part AFTER the first
|
||
// " - " (assuming "Artist - Title"), which mis-parses titles that themselves contain
|
||
// " - " (e.g. "Kingdom Hearts 2 - Dearly Beloved - Main Theme") or filenames with a
|
||
// garbage/absent artist prefix ("01-01 - …") → the duplicate went undetected and got
|
||
// re-downloaded. Now match if ANY reading equals the query title:
|
||
// 1) the FULL stem (title with internal dashes, or no clean artist prefix)
|
||
// 2) the part after the first " - " (normal "Artist - Title")
|
||
const di = stem.indexOf(' - ');
|
||
const candidates = di >= 0 ? [stem, stem.slice(di + 3)] : [stem];
|
||
for (const cand of candidates) {
|
||
if (titleVariants.has(norm(cand.replace(_RELEASE_SUFFIX_RE, '')))) 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,
|
||
};
|