222 lines
11 KiB
JavaScript
222 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);
|
||
}
|
||
|
||
// Welcher Kuenstler in den DATEINAMEN gehoert. Bisher stand dort die vollstaendige Gaesteliste
|
||
// ("K_DA,Madison Beer,i-dle,Lexie Liu,Jaira Burns,Seraphine,League of Legends"). Das ist nirgends
|
||
// ueblich — Picard, beets und Lidarr schreiben hoechstens einen Kuenstler in den Namen —, und es
|
||
// hatte zwei handfeste Nachteile: sanitize() schneidet bei 100 Zeichen hart ab, also mitten im
|
||
// letzten Namen, und mit dem Dateisystemlimit von 255 Byte wird es bei mehrbytigen Namen eng.
|
||
// Die vollstaendige Liste steht ohnehin im artist-Tag, und nur den liest Navidrome.
|
||
// Genommen wird der ERSTE Kuenstler — bei gemischten Alben der des Titels, nicht der des Albums,
|
||
// damit ein Soundtrack-Titel im Namen weiterhin seinen eigenen Interpreten traegt.
|
||
function dateinamensKuenstler(artist, fallback) {
|
||
const erster = (s) => String(s || '').split(/[;,]|\s+&\s+/)[0].trim();
|
||
return erster(artist) || erster(fallback) || '';
|
||
}
|
||
|
||
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 || '')
|
||
// Akzente abtrennen und verwerfen: dieselbe Kuenstlerin kommt je nach Quelle als
|
||
// "Cécile Corbel" oder "Cecile Corbel" — ohne das galt sie als zwei Personen und derselbe
|
||
// Song wurde ein zweites Mal geladen. Betrifft nur den VERGLEICH, nicht Tags oder Dateinamen.
|
||
.normalize('NFD').replace(/\p{M}+/gu, '')
|
||
.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, ' ')
|
||
// Auch die typografischen Apostrophe: Anbieter schreiben "Arrietty’s Song" (U+2019), eine
|
||
// spaetere Anfrage kommt mit geradem "'" — ohne das galten beide als verschiedene Titel.
|
||
.replace(/[,.!'\u2018\u2019\u02bc\u00b4`]/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;
|
||
}
|
||
|
||
const AUDIO_EXT_RE = /\.(mp3|flac|opus|m4a|ogg|wav)$/i;
|
||
|
||
// Vereinheitlicht einen Dateinamen-Stamm fuer den lockeren Vergleich:
|
||
// 1. fuehrende Track-Praefixe wie "01-15 – " oder "01 - " abschneiden
|
||
// 2. alle Strichvarianten (en/em dash) auf " - " bringen
|
||
function normStem(s) {
|
||
return s
|
||
.replace(/^\d[\d\-–—]*\s*[-–—]\s*/, '')
|
||
.replace(/\s*[–—]\s*/g, ' - ');
|
||
}
|
||
|
||
// Tiefensuche nach einer Audiodatei, deren Stamm zum Titel passt. Letzter Ausweg, wenn der von
|
||
// Navidrome gemeldete Pfad nicht existiert (Ordner umbenannt, Tag-Pfad statt echtem Pfad).
|
||
//
|
||
// WICHTIG: der Titel muss ein VOLLSTAENDIGES Feld des Dateinamens sein, nie nur dessen Ende.
|
||
// Ein blosses endsWith(titel) liess "Bad Omens - Like A Villain" als Treffer fuer den Titel
|
||
// "VILLAIN" durchgehen — ein voellig anderer Song derselben Endsilbe. Genau solche Fehlgriffe
|
||
// landen sonst im Metadaten-Editor oder, schlimmer, im Loeschen-Endpunkt.
|
||
function findFileByStem(dir, titleStem, depth = 0) {
|
||
if (depth > 4) return null;
|
||
const normTitle = normStem(titleStem);
|
||
try {
|
||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||
if (entry.isDirectory()) {
|
||
const found = findFileByStem(path.join(dir, entry.name), titleStem, depth + 1);
|
||
if (found) return found;
|
||
} else if (AUDIO_EXT_RE.test(entry.name)) {
|
||
const bare = entry.name.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '');
|
||
const bareStem = normStem(bare.toLowerCase());
|
||
// Treffer: exakt, "Kuenstler - Titel", oder Release-Suffix-Versatz ("… - Single").
|
||
if (bareStem === normTitle ||
|
||
bareStem.endsWith(' - ' + normTitle) ||
|
||
bareStem.startsWith(normTitle + ' - ') ||
|
||
normTitle.startsWith(bareStem + ' - ')) {
|
||
return path.join(dir, entry.name);
|
||
}
|
||
}
|
||
}
|
||
} catch {}
|
||
return null;
|
||
}
|
||
|
||
// Navidrome gibt ueber die Subsonic-API standardmaessig keinen echten Pfad heraus, sondern baut
|
||
// einen aus den Tags: {AlbumArtist}/{Album}/{DD}-{TT} - {Titel}.{Endung}. Steht ein "/" IM Namen
|
||
// — "K/DA", "POP/STARS" —, wird daraus eine zusaetzliche Verzeichnisebene, waehrend auf der Platte
|
||
// sanitize() ein "_" geschrieben hat ("K_DA/POP_STARS"). Der gemeldete Pfad existiert dann nicht,
|
||
// und die Suche faellt auf Aehnlichkeit zurueck. Diese Funktion baut den Ordner stattdessen genau
|
||
// so zusammen, wie der Downloader ihn angelegt haette.
|
||
function albumDirFromTags(musicRoot, albumArtist, album) {
|
||
if (!musicRoot || !albumArtist) return null;
|
||
// Der Downloader legt den Ordner unter dem ERSTEN Kuenstler an (mainArtist.split(/[,;&]/)[0]).
|
||
const ersterKuenstler = String(albumArtist).split(/[,;&]/)[0].trim();
|
||
const kandidaten = [];
|
||
for (const k of new Set([ersterKuenstler, String(albumArtist).trim()])) {
|
||
if (!k) continue;
|
||
if (album) kandidaten.push(path.join(musicRoot, sanitize(k), sanitize(album)));
|
||
kandidaten.push(path.join(musicRoot, sanitize(k)));
|
||
}
|
||
for (const c of kandidaten) {
|
||
try { if (fs.statSync(c).isDirectory()) return c; } 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, dateinamensKuenstler,
|
||
downloadArtwork, findExistingTrack,
|
||
normStem, findFileByStem, albumDirFromTags,
|
||
};
|