Files

222 lines
11 KiB
JavaScript
Raw Permalink 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);
}
// 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,
};