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 || '') // 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, downloadArtwork, findExistingTrack, normStem, findFileByStem, albumDirFromTags, };