/** * NovaSonic Backend Server * Downloads music via yt-dlp / squid.wtf (Qobuz) and saves into your Navidrome library. * * Requirements: * - Node.js 18+ * - yt-dlp installed (https://github.com/yt-dlp/yt-dlp) * - ffmpeg installed */ const express = require('express'); const { execSync, spawn } = require('child_process'); const path = require('path'); const fs = require('fs'); const https = require('https'); const crypto = require('crypto'); const CONFIG = require('./lib/config'); const { jobs, updateJob, log } = require('./lib/jobs'); const { sanitize, findExistingTrack } = require('./lib/metadata'); const dz = require('./lib/deezer'); const tm = require('./lib/trackmeta'); // Deezer → MusicBrainz → clean fallback (single tracks) const { downloadTrack, downloadAlbumTracks } = require('./lib/downloader'); const { buildRomajiIndex, clearRomajiCache, isTokenizerReady, fetchNdSongs, romajiForStorage } = require('./lib/romaji'); // ─── INIT ───────────────────────────────────────────────────────────────────── const app = express(); app.use((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PATCH,DELETE,OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization,X-ABS-URL,X-ABS-Token'); res.setHeader('Access-Control-Allow-Private-Network', 'true'); if (req.method === 'OPTIONS') { res.sendStatus(204); return; } next(); }); app.use(express.json({ limit: '10mb' })); let ytdlpVersion = 'unknown'; try { ytdlpVersion = execSync(`${CONFIG.ytdlpBin} --version`, { timeout: 5000 }).toString().trim(); console.log(`✅ yt-dlp ${ytdlpVersion} found`); } catch { console.warn('⚠️ yt-dlp not found!'); } if (!fs.existsSync(CONFIG.musicPath)) { console.warn(`⚠️ Music path "${CONFIG.musicPath}" does not exist — creating it`); fs.mkdirSync(CONFIG.musicPath, { recursive: true }); } // Search for a file by exact name anywhere under a directory (max depth 5) function _findFileRecursive(dir, name, depth = 0) { if (depth > 5) return null; try { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.isDirectory()) { const found = _findFileRecursive(path.join(dir, entry.name), name, depth + 1); if (found) return found; } else if (entry.name === name) { return path.join(dir, entry.name); } } } catch {} return null; } // When Navidrome runs in a different container its paths start with a different root // (e.g. /opt/navidrome/music) than the backend's mount (e.g. /music). // Strip leading components from the absolute path and try joining with CONFIG.musicPath. function _rebasePath(absolutePath, musicPath) { const parts = absolutePath.split('/').filter(Boolean); for (let i = 1; i < parts.length; i++) { const candidate = path.join(musicPath, ...parts.slice(i)); if (fs.existsSync(candidate)) return candidate; } return null; } // When exact filename fails (e.g. file was renamed to include artist), // look in the same dir for any audio file sharing the track-number prefix or title stem. const AUDIO_EXT_RE = /\.(mp3|flac|opus|m4a|ogg|wav)$/i; function _findFileInDir(dir, basename) { try { const m = basename.match(/^(\d+\s*-\s*)/); const prefix = m ? m[1] : null; const stem = basename.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase(); for (const entry of fs.readdirSync(dir)) { if (!AUDIO_EXT_RE.test(entry)) continue; if (prefix && entry.startsWith(prefix)) return path.join(dir, entry); if (entry.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase() === stem) return path.join(dir, entry); } } catch {} return null; } // Last-resort: deep recursive search by title stem (handles folder renames after metadata edit). // Convention: MusicRoot/AlbumArtist/Album/{01 - }Artist - Title.ext // The title stem is everything after the optional "NN - Artist - " prefix. // We match any audio file whose stem ends with the same title words. // Normalize a file stem for loose matching: // 1. Strip compound track prefixes like "01-15 – " or "01 - " (handles en-dash, em-dash, hyphen) // 2. Normalize all dash variants (en-dash, em-dash) to " - " function _normStem(s) { return s .replace(/^\d[\d\-–—]*\s*[-–—]\s*/, '') // strip leading "01-15 – " or "01 - " .replace(/\s*[–—]\s*/g, ' - '); // normalize remaining en/em-dash to " - " } 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)) { // Strip extension + leading track prefix, then normalize dashes const bare = entry.name.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, ''); const bareStem = _normStem(bare.toLowerCase()); // Match: exact, or "Artist - Title" ending with our title stem if (bareStem === normTitle || bareStem.endsWith(' - ' + normTitle) || bareStem.endsWith(normTitle) || // Release-suffix desync: Navidrome's title-derived stem ("… - recognize") // may lack the "- Single"/"- EP" suffix the on-disk filename still carries // ("… - recognize - single"), or vice-versa. Match on the structured prefix. bareStem.startsWith(normTitle + ' - ') || normTitle.startsWith(bareStem + ' - ')) { return path.join(dir, entry.name); } } } } catch {} return null; } // Like _findFileByStem but also tries a romaji-converted stem. // Handles the case where Navidrome has a Japanese path in its DB but the file // on disk was stored with a romaji filename by the download pipeline. function _findFileByStemOrRomaji(dir, stem) { const direct = _findFileByStem(dir, stem); if (direct) return direct; try { const rt = (romajiForStorage(stem) || '').toLowerCase().trim(); if (rt && rt !== stem) return _findFileByStem(dir, rt); } catch {} return null; } // ─── QUEUE ──────────────────────────────────────────────────────────────────── let activeDownloads = 0; const downloadQueue = []; function processQueue() { if (activeDownloads >= CONFIG.maxParallel || downloadQueue.length === 0) return; const task = downloadQueue.shift(); activeDownloads++; task().finally(() => { activeDownloads--; processQueue(); }); } function enqueue(task) { downloadQueue.push(task); processQueue(); } // ─── SPOTIFY ────────────────────────────────────────────────────────────────── function parseSpotifyUrl(url) { const m = url.match(/open\.spotify\.com\/(?:[a-z]{2,8}-[a-z]{2,8}\/)?(?:intl-[a-z-]+\/)?(track|album|playlist)\/([A-Za-z0-9]+)/); if (!m) throw new Error('Ungültige Spotify-URL — erwartet: open.spotify.com/track|album|playlist/...'); return { type: m[1], id: m[2] }; } // ─── ROUTES ────────────────────────────────────────────────────────────────── // GET /api/romaji-index?ndUrl=...&user=...&pass=... app.get('/api/romaji-index', async (req, res) => { const { ndUrl, user, pass } = req.query; if (!ndUrl || !user || !pass) return res.status(400).json({ error: 'missing params' }); if (!isTokenizerReady()) return res.status(503).json({ error: 'tokenizer not ready — try again in a few seconds' }); try { const { fromCache, index } = await buildRomajiIndex(ndUrl, user, pass); if (!fromCache) console.log(`✅ Romaji index built: ${index.length} JP songs`); res.json(index); } catch (e) { console.error('Romaji index error:', e); res.status(500).json({ error: e.message }); } }); // DELETE /api/romaji-index — force rebuild on next request app.delete('/api/romaji-index', (req, res) => { clearRomajiCache(); res.json({ ok: true }); }); // ─── ROMAJI OVERRIDES (server-side, shared across all clients) ─────────────── const _overridesFile = path.join(CONFIG.dataPath, 'romaji-overrides.json'); function _loadOverrides() { try { return JSON.parse(fs.readFileSync(_overridesFile, 'utf8')); } catch { return {}; } } function _saveOverrides(map) { try { fs.mkdirSync(CONFIG.dataPath, { recursive: true }); fs.writeFileSync(_overridesFile, JSON.stringify(map, null, 2), 'utf8'); } catch (e) { console.error('Failed to save romaji overrides:', e.message); } } // GET /api/romaji-overrides — returns the full kanji→romaji map app.get('/api/romaji-overrides', (_req, res) => res.json(_loadOverrides())); // POST /api/romaji-overrides — upsert { kanji, romaji } app.post('/api/romaji-overrides', (req, res) => { const { kanji, romaji } = req.body; if (!kanji?.trim()) return res.status(400).json({ error: 'kanji required' }); const map = _loadOverrides(); if (romaji?.trim()) map[kanji.trim()] = romaji.trim(); else delete map[kanji.trim()]; _saveOverrides(map); clearRomajiCache(); // rebuild romaji index so display picks up the change res.json({ ok: true }); }); // DELETE /api/romaji-overrides/:kanji — remove one entry app.delete('/api/romaji-overrides/:kanji', (req, res) => { const kanji = decodeURIComponent(req.params.kanji); const map = _loadOverrides(); delete map[kanji]; _saveOverrides(map); clearRomajiCache(); res.json({ ok: true }); }); // POST /api/romaji-convert — convert an array of strings to romaji app.post('/api/romaji-convert', (req, res) => { const { texts } = req.body; if (!Array.isArray(texts)) return res.status(400).json({ error: 'texts must be array' }); if (!isTokenizerReady()) return res.status(503).json({ error: 'tokenizer not ready' }); res.json({ results: texts.map(t => romajiForStorage(t) || t) }); }); // GET /api/genre-playcounts?ndUrl=...&user=...&pass=... // Returns { [genre]: totalPlayCount } sorted by plays desc, cached for 20 min. const _genrePlayCache = new Map(); const GENRE_PLAY_TTL = 20 * 60 * 1000; app.get('/api/genre-playcounts', async (req, res) => { const { ndUrl, user, pass } = req.query; if (!ndUrl || !user || !pass) return res.status(400).json({ error: 'missing params' }); const key = `${ndUrl}|${user}`; const cached = _genrePlayCache.get(key); if (cached && Date.now() - cached.ts < GENRE_PLAY_TTL) return res.json(cached.data); try { const songs = await fetchNdSongs(ndUrl, user, pass); const totals = {}; for (const s of songs) { const g = s.genre; if (!g || !(s.playCount > 0)) continue; totals[g] = (totals[g] || 0) + s.playCount; } _genrePlayCache.set(key, { ts: Date.now(), data: totals }); res.json(totals); } catch (e) { console.error('genre-playcounts error:', e); res.status(500).json({ error: e.message }); } }); app.post('/api/spotify/track-artwork', async (req, res) => { const tracks = req.body.tracks || []; if (!tracks.length) return res.json({ results: [] }); const CONC = 8; const results = []; for (let i = 0; i < tracks.length; i += CONC) { const batch = tracks.slice(i, i + CONC); const batchRes = await Promise.all(batch.map(async t => { const searchArtist = (t.artistName || '').split(/\s*[,/•]\s*/)[0].trim(); const meta = await dz.lookupTrackMeta(searchArtist, t.trackName || ''); return { idx: t.idx, artworkUrl: meta.artworkUrl || t.existingArt || '' }; })); results.push(...batchRes); } res.json({ results }); }); // ─── METADATA-EDITOR AUTOFILL ──────────────────────────────────────────────── // Fills the editor from Deezer given artist + title (replaces the old iTunes autofill). app.get('/api/meta/lookup', async (req, res) => { const { artist, title } = req.query; if (!artist || !title) return res.status(400).json({ error: 'artist and title required' }); try { // Editor "Deezer" button = Deezer only (there is a separate "MusicBrainz" button). The combined // Deezer→MusicBrainz→clean resolver (tm) is used by the DOWNLOAD paths, not this manual autofill. const meta = await dz.lookupTrackMeta(artist, title); if (!meta.album && !meta.genre && !meta.year) return res.json({ found: false }); res.json({ found: true, title: meta.trackName || undefined, artist: meta.metaArtistName || undefined, albumArtist: meta.albumArtist || undefined, album: meta.album || undefined, year: meta.year || undefined, trackNum: meta.trackNum || undefined, totalTracks: meta.totalTracks || undefined, discNum: meta.discNum || undefined, genre: meta.genre || undefined, coverUrl: meta.artworkUrl || undefined, }); } catch (e) { console.error('[meta/lookup]', e.message); res.status(500).json({ error: e.message }); } }); // Deezer API proxy — the metadata source for search, charts, album/artist lookups (Apple's /search // returns 403 from server IPs). Free, no key, returns explicit versions + artist images + durations. // Covers /search, /search/album, /search/artist, /album/{id}, /artist/{id}/albums, /chart/…, etc. // Regex route (not '/api/deezer/*') — a bare "*" is an invalid path pattern in Express 5 and // crashes the server at startup, taking every endpoint down with it. app.get(/^\/api\/deezer\/(.+)/, (req, res) => { const sub = String(req.params[0] || '').replace(/[^a-zA-Z0-9/_-]/g, ''); const qs = new URLSearchParams(req.query).toString(); const target = `https://api.deezer.com/${sub}${qs ? '?' + qs : ''}`; https.get(target, { headers: { 'User-Agent': 'NovaSonic/1.0' } }, upstream => { res.setHeader('Content-Type', 'application/json'); res.status(upstream.statusCode || 200); upstream.pipe(res); }).on('error', e => res.status(502).json({ error: e.message })); }); app.get('/api/events/:artist', (req, res) => { const key = CONFIG.ticketmasterKey; if (!key) { console.warn('[events] TICKETMASTER_API_KEY not set'); return res.json([]); } const artist = req.params.artist; const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); // 2024-06-08T12:00:00Z const url = `https://app.ticketmaster.com/discovery/v2/events.json` + `?keyword=${encodeURIComponent(artist)}&classificationName=music` + `&startDateTime=${now}&size=20&sort=date,asc` + `&apikey=${key}`; https.get(url, { headers: { 'Accept': 'application/json' } }, upstream => { let body = ''; upstream.on('data', d => body += d); upstream.on('end', () => { try { const data = JSON.parse(body); if (upstream.statusCode !== 200 || data.fault || data.errors) { console.error('[events] Ticketmaster error:', upstream.statusCode, JSON.stringify(data).slice(0, 200)); return res.json([]); } const raw = data?._embedded?.events || []; const events = raw.map(e => { const venue = e._embedded?.venues?.[0] || {}; const start = e.dates?.start || {}; const dt = start.dateTime || (start.localDate ? `${start.localDate}T${start.localTime || '00:00:00'}` : null); const soldOut = e.dates?.status?.code === 'offsale' || e.dates?.status?.code === 'cancelled'; return { datetime: dt, venue: { name: venue.name || '', city: venue.city?.name || '', country: venue.country?.name || '', }, offers: e.url ? [{ url: e.url, status: soldOut ? 'unavailable' : 'available' }] : [], }; }).filter(e => e.datetime); console.log(`[events] ${artist}: ${events.length} events`); res.json(events); } catch(err) { console.error('[events] parse error:', err); res.json([]); } }); }).on('error', e => { console.error('[events] request error:', e.message); res.json([]); }); }); // Debug-only: returns raw Ticketmaster response so you can see exactly what the API sends back. // Visit /api/events-debug/Maduk in your browser to test. app.get('/api/events-debug/:artist', (req, res) => { const key = CONFIG.ticketmasterKey; if (!key) return res.json({ error: 'TICKETMASTER_API_KEY not set in .env' }); const artist = req.params.artist; const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); const url = `https://app.ticketmaster.com/discovery/v2/events.json` + `?keyword=${encodeURIComponent(artist)}&classificationName=music` + `&startDateTime=${now}&size=5&sort=date,asc&apikey=${key}`; https.get(url, { headers: { 'Accept': 'application/json' } }, upstream => { let body = ''; upstream.on('data', d => body += d); upstream.on('end', () => { try { res.json({ status: upstream.statusCode, url: url.replace(key, 'KEY_HIDDEN'), body: JSON.parse(body) }); } catch { res.json({ status: upstream.statusCode, raw: body.slice(0, 500) }); } }); }).on('error', e => res.json({ error: e.message })); }); app.get('/api/health', (req, res) => { res.json({ status: 'ok', ytdlpVersion, musicPath: CONFIG.musicPath, audioFormat: CONFIG.audioFmt, downloadBackend: CONFIG.downloadBackend, activeJobs: activeDownloads, queueLength: downloadQueue.length, spotify: { configured: true }, }); }); async function _resolveSpotifyViaSpotdl(url) { const { execFile } = require('child_process'); const scriptPath = path.join(__dirname, 'spotify_resolve.py'); // Strip ?si= and other tracking params — spotdl doesn't handle them const cleanUrl = url.split('?')[0]; return new Promise((resolve, reject) => { execFile('python3', [scriptPath, cleanUrl], { timeout: 90000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => { if (stderr?.trim()) stderr.trim().split('\n').forEach(l => console.log('[py]', l)); // Always try to parse stdout first — script outputs JSON even on error const out = stdout?.trim(); if (out) { try { const data = JSON.parse(out); if (data.error === 'RATE_LIMITED') return reject(new Error(`RATE_LIMITED:${data.retryAfter || 30}`)); if (data.error) return reject(new Error(`spotdl: ${data.error}`)); return resolve(data); } catch { /* not JSON, fall through */ } } if (err) return reject(new Error(`spotdl exited ${err.code}: ${stderr?.trim() || out || err.message}`)); reject(new Error(`spotdl: keine Ausgabe`)); }); }); } // Tracks when the Spotify rate limit window expires so retries never reach Spotify. let _spRateLimitUntil = 0; app.post('/api/spotify/resolve', async (req, res) => { const { url } = req.body; if (!url) return res.status(400).json({ error: 'url required' }); // Block retries until the cooldown the Spotify told us has actually elapsed. const now = Date.now(); if (_spRateLimitUntil > now) { const secs = Math.ceil((_spRateLimitUntil - now) / 1000); console.log(`[spotify/resolve] cooldown active — ${secs}s remaining`); return res.status(429).json({ error: 'RATE_LIMITED', retryAfter: secs }); } try { const { type, id } = parseSpotifyUrl(url); console.log(`[spotify/resolve] ${type} ${id}`); const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Zeitüberschreitung — Spotify API antwortet nicht (>120s)')), 120000) ); const data = await Promise.race([_resolveSpotifyViaSpotdl(url), timeout]); res.json(data); } catch(e) { console.error('[spotify/resolve]', e.message); if (e.message.includes('403') && e.message.includes('/playlists/')) { return res.status(403).json({ error: 'EDITORIAL_PLAYLIST' }); } if (e.message.startsWith('RATE_LIMITED:')) { const secs = parseInt(e.message.split(':')[1], 10) || 30; _spRateLimitUntil = Date.now() + secs * 1000; return res.status(429).json({ error: 'RATE_LIMITED', retryAfter: secs }); } res.status(500).json({ error: e.message }); } }); // Normalize user-supplied artist strings — accepts •, ·, ;, or , as separators // and rejoins with the canonical sep ("; " for track artist, ", " for album artist). function _normalizeArtists(str, sep) { if (!str) return str; // If an explicit separator is present use it; otherwise fall back to splitting on spaces // so the user can type "Artist1 Artist2" without a semicolon. return str.split(/\s*[•·;,]\s*/).map(s => s.trim()).filter(Boolean).join(sep); } // Strip " - Topic" suffix YouTube Music appends to artist channel names function _cleanYtArtist(name) { return (name || '').replace(/\s*-\s*Topic$/i, '').trim(); } app.post('/api/youtube/resolve', async (req, res) => { const { url } = req.body; if (!url) return res.status(400).json({ error: 'url required' }); try { const { execFile } = require('child_process'); const stdout = await new Promise((resolve, reject) => { execFile(CONFIG.ytdlpBin, [ '--dump-json', '--flat-playlist', '--no-warnings', '--playlist-items', '1-50', url, ], { timeout: 45000, maxBuffer: 20 * 1024 * 1024 }, (err, out, stderr) => { if (err && !out?.trim()) return reject(new Error(stderr?.trim() || err.message)); resolve(out || ''); }); }); const entries = stdout.trim().split('\n') .map(l => { try { return JSON.parse(l.trim()); } catch { return null; } }) .filter(Boolean); if (!entries.length) return res.status(404).json({ error: 'Keine Ergebnisse von yt-dlp' }); const isPlaylist = entries.length > 1 || entries[0]?._type === 'playlist'; if (isPlaylist) { const plTitle = entries[0]?.playlist_title || entries[0]?.playlist || 'YouTube Playlist'; const plArtist = _cleanYtArtist(entries[0]?.uploader || entries[0]?.channel || ''); const tracks = entries.map(e => ({ trackName: e.title || '', artistName: _cleanYtArtist(e.uploader || e.channel || plArtist), artworkUrl: e.thumbnail || '', durationMs: e.duration ? Math.round(e.duration * 1000) : undefined, sourceUrl: e.webpage_url || e.url || (e.id ? `https://www.youtube.com/watch?v=${e.id}` : ''), })); return res.json({ type: 'playlist', name: plTitle, artist: plArtist, artworkUrl: entries[0]?.thumbnail || '', tracks }); } // Single video — enrich with Deezer const e = entries[0]; const rawArtist = _cleanYtArtist(e.uploader || e.channel || e.creator || ''); const title = e.title || ''; const sourceUrl = e.webpage_url || url; let meta = {}; try { meta = await tm.lookupTrackMeta(rawArtist.split(',')[0].trim(), title); } catch {} const artist = meta.artist || rawArtist; const artworkUrl = meta.artworkUrl || e.thumbnail || ''; return res.json({ type: 'track', name: title, artist, artworkUrl, tracks: [{ trackName: title, artistName: artist, artworkUrl: artworkUrl, collectionName: meta.album || '', releaseDate: meta.year ? String(meta.year) : '', trackNumber: meta.trackNum || undefined, durationMs: meta.durationMs || (e.duration ? Math.round(e.duration * 1000) : undefined), genre: meta.genre || '', sourceUrl, }], }); } catch (e) { console.error('[youtube/resolve]', e.message); res.status(500).json({ error: e.message }); } }); app.post('/api/download', async (req, res) => { const { type, artist, title, album, artworkUrl, collectionId, jobId: clientJobId, explicit, trackNum: bodyTrackNum, totalTracks: bodyTotalTracks, discNum: bodyDiscNum, discTotal: bodyDiscTotal, sourceUrl } = req.body; if (!artist || !title) return res.status(400).json({ error: 'artist and title required' }); const jobId = clientJobId || ('job_' + Date.now()); jobs.set(jobId, { id: jobId, type, artist, title, album, status: 'queued', progress: 0, message: 'In Warteschlange…', log: [], createdAt: Date.now(), updatedAt: Date.now(), }); res.json({ jobId, status: 'queued' }); enqueue(async () => { updateJob(jobId, { status: 'running', progress: 5, message: 'Gestartet…' }); try { if (type === 'track') { const sp = req.body.tracks?.[0] || {}; const spAlbum = sp.collectionName || sp.album || album || ''; const spArt = sp.artworkUrl100 || sp.artworkUrl || artworkUrl || ''; const spYear = sp.releaseDate ? new Date(sp.releaseDate).getFullYear() : (sp.year || undefined); let meta = { album: spAlbum, year: spYear, trackNum: sp.trackNumber || bodyTrackNum || undefined, totalTracks: sp.trackCount || bodyTotalTracks || undefined, discNum: sp.discNumber || bodyDiscNum || undefined, discTotal: sp.discCount || bodyDiscTotal || undefined, genre: sp.primaryGenreName || sp.genre || undefined, artworkUrl: spArt, explicit: sp.trackExplicitness === 'explicit' || !!explicit, durationMs: sp.durationMs || req.body.durationMs || undefined, }; const searchArtist = artist.split(',')[0].trim(); if (!meta.album) { log(jobId, '📋 Suche Album-Metadaten von Deezer…'); const extra = await tm.lookupTrackMeta(searchArtist, title); if (extra.album) log(jobId, `📋 Album: "${extra.album}" (${extra.year || '?'})`); meta = { ...meta, ...extra, artworkUrl: spArt || extra.artworkUrl || '', genre: meta.genre || extra.genre || undefined }; } else { log(jobId, `📋 Album: "${meta.album}"`); // Always fetch Deezer when any field is missing — durationMs is required for the yt-dlp // duration filter; without it any unrelated video that matches the title text gets downloaded. if (!meta.genre || !meta.totalTracks || !meta.durationMs || !meta.trackNum || !meta.year) { // Strip release-type suffixes before the Deezer lookup so "recognize - Single" → "recognize" 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; const lookupTitle = title.replace(RELEASE_SUFFIX_RE, '').trim() || title; const genreMeta = await dz.lookupTrackMeta(searchArtist, lookupTitle).catch(() => ({})); if (!meta.genre && genreMeta.genre) { meta.genre = genreMeta.genre; log(jobId, `📋 Genre: "${genreMeta.genre}"`); } if (!meta.year && genreMeta.year) { meta.year = genreMeta.year; log(jobId, `📋 Jahr: ${genreMeta.year}`); } if (!meta.totalTracks && genreMeta.totalTracks) { meta.totalTracks = genreMeta.totalTracks; } if (!meta.discNum && genreMeta.discNum) { meta.discNum = genreMeta.discNum; } if (!meta.discTotal && genreMeta.discTotal) { meta.discTotal = genreMeta.discTotal; } if (!meta.durationMs && genreMeta.durationMs) { meta.durationMs = genreMeta.durationMs; log(jobId, `📋 Duration: ${Math.round(genreMeta.durationMs/1000)}s`); } if (!meta.trackNum && genreMeta.trackNum) { meta.trackNum = genreMeta.trackNum; } } } const finalArtist = meta.metaArtistName || artist; const result = await downloadTrack({ jobId, artist: finalArtist, rawArtist: artist, title, ...meta, sourceUrl: sourceUrl || sp.sourceUrl || undefined }); if (result?.skipped) { updateJob(jobId, { status: 'done', progress: 100, message: 'Bereits vorhanden – übersprungen', skipped: true }); } else if (result?.notFound) { updateJob(jobId, { status: 'error', message: 'Nicht eindeutig gefunden — bitte Direktlink im Importer nutzen' }); } else { updateJob(jobId, { status: 'done', progress: 100, message: 'Fertig!' }); } } else if (type === 'album') { // Prefer a track list supplied in the request body (Deezer albums send their tracks directly, // pre-mapped to the ItunesItem shape). Fall back to a Deezer lookup for legacy callers. let tracks = Array.isArray(req.body.tracks) ? req.body.tracks : []; if (tracks.length) log(jobId, `📋 ${tracks.length} Tracks aus Anfrage (Deezer)`); if (!tracks.length && collectionId) { log(jobId, '📋 Lade Track-Liste von Deezer…'); tracks = await dz.fetchAlbumTracks(collectionId); } if (!tracks.length) { log(jobId, '🔍 Suche Album auf Deezer…'); tracks = await dz.searchAlbumTracks(artist, album || title); } if (!tracks.length) { throw new Error(`Keine einzelnen Tracks für "${album || title}" gefunden. Bitte über die Global-Suche herunterladen.`); } // Album artist: prefer the album-level collectionArtistName (identical across every track) // over the request's `artist`, which can be a single track's performer — e.g. the lead single // "TAKEDOWN" is by TWICE, so `artist` arrives as "TWICE" and would tag the whole soundtrack's // album_artist as TWICE. Keeps album_artist consistent so Navidrome doesn't split the album. const albumArtistName = tracks.map(t => t.collectionArtistName).find(Boolean) || artist; if (albumArtistName !== artist) log(jobId, `👥 Album-Künstler: "${albumArtistName}" (Deezer)`); const { done: dlDone, skipped: dlSkipped } = await downloadAlbumTracks({ jobId, artist: albumArtistName, albumTitle: album || title, tracks, artworkUrl }); const allSkipped = dlSkipped === tracks.length; const someSkipped = dlSkipped > 0 && !allSkipped; const msg = allSkipped ? `Album bereits vorhanden – alle ${dlSkipped} Tracks übersprungen` : someSkipped ? `Album fertig! (${dlDone} geladen, ${dlSkipped} bereits vorhanden)` : `Album "${album || title}" fertig!`; updateJob(jobId, { status: 'done', progress: 100, message: msg, skipped: allSkipped }); } else if (type === 'artist') { log(jobId, `🎤 Künstler-Download: ${artist}`); // Deezer discography: search the artist, take their real albums (not singles/EPs). const artistAlbums = await dz.fetchArtistAlbums(artist, 10).catch(() => []); log(jobId, `📀 ${artistAlbums.length} Alben gefunden`); let done = 0; for (const alb of artistAlbums) { const tracks = await dz.fetchAlbumTracks(alb.collectionId).catch(() => []); if (tracks.length) { // Same as the album path: album_artist from the album-level artist, so a collaboration // or soundtrack in the artist's discography isn't tagged with the searched artist. const albArtist = tracks.map(t => t.collectionArtistName).find(Boolean) || alb.artistName || artist; await downloadAlbumTracks({ jobId, artist: albArtist, albumTitle: alb.collectionName, tracks, artworkUrl: alb.artworkUrl100 }); } done++; updateJob(jobId, { progress: Math.round((done / artistAlbums.length) * 95) }); } updateJob(jobId, { status: 'done', progress: 100, message: `Künstler "${artist}" fertig!` }); } else if (type === 'playlist') { const plTracks = req.body.tracks || []; if (!plTracks.length) throw new Error('Keine Tracks übergeben'); log(jobId, `🎵 Playlist "${title}" — ${plTracks.length} Tracks`); let done = 0, skipped = 0, notFound = 0; const CONC = CONFIG.maxParallel || 2; for (let i = 0; i < plTracks.length; i += CONC) { const batch = plTracks.slice(i, i + CONC); const batchResults = await Promise.allSettled(batch.map(async track => { const trackTitle = track.trackName || ''; const trackArtist = track.artistName || artist || ''; // Strip release-type suffixes before the Deezer lookup ("Song - Single" → "Song") 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; const lookupTitle = trackTitle.replace(RELEASE_SUFFIX_RE, '').trim() || trackTitle; // If the frontend already enriched this track with Deezer data (collectionName + // trackNumber + durationMs all present), skip the re-lookup to avoid a different // Deezer result overriding the already-correct enriched metadata. const alreadyEnriched = !!(track.collectionName && track.trackNumber && track.durationMs); const searchArtist = trackArtist.split(',')[0].trim(); let meta = {}; if (alreadyEnriched) { log(jobId, `📋 Frontend-Metadaten: "${trackArtist} – ${trackTitle}" (${track.collectionName})`); // Spotify enrichment copies album/trackNum/duration but not genre/totalTracks/disc — fetch those const genreMeta = await dz.lookupTrackMeta(searchArtist, lookupTitle).catch(() => ({})); meta = { genre: genreMeta.genre, totalTracks: genreMeta.totalTracks, discNum: genreMeta.discNum, discTotal: genreMeta.discTotal, }; } else { log(jobId, `📋 Deezer: "${trackArtist} – ${trackTitle}"`); meta = await dz.lookupTrackMeta(searchArtist, lookupTitle); } // Prefer the resolved name (from server lookup or frontend enrichment) for sort tags / folders. // trackArtist stays as rawArtist for main tags and YTM search. const resolvedArtist = meta.metaArtistName || track.metaArtistName || trackArtist; return downloadTrack({ jobId, artist: resolvedArtist, // resolved (Deezer) name → used for sort tags + folder names rawArtist: trackArtist, // original Spotify/Japanese name → used for main tag + YTM search albumArtist: resolvedArtist, title: trackTitle, album: meta.album || track.collectionName || track.album || '', year: meta.year || (track.releaseDate ? new Date(track.releaseDate).getFullYear() : undefined) || track.year, trackNum: meta.trackNum || track.trackNumber || undefined, totalTracks: meta.totalTracks || undefined, genre: meta.genre || track.primaryGenreName || track.genre || undefined, artworkUrl: meta.artworkUrl || track.artworkUrl100 || track.artworkUrl || artworkUrl || '', explicit: track.trackExplicitness === 'explicit', durationMs: meta.durationMs || track.durationMs || undefined, sourceUrl: track.sourceUrl || undefined, }); })); for (let j = 0; j < batchResults.length; j++) { const r = batchResults[j]; if (r.status === 'rejected') { log(jobId, `⚠️ Fehler bei "${batch[j].trackName || '?'}": ${r.reason?.message || r.reason}`); } else if (r.value?.skipped) { skipped++; } else if (r.value?.notFound) { notFound++; log(jobId, `⚠️ Nicht eindeutig gefunden: "${batch[j].trackName || '?'}" — Direktlink nutzen`); } else { done++; } updateJob(jobId, { progress: Math.round(((done + skipped + notFound) / plTracks.length) * 90) }); } } const allSkipped = skipped === plTracks.length; updateJob(jobId, { status: 'done', progress: 100, skipped: allSkipped, message: allSkipped ? `Alle ${skipped} Tracks bereits vorhanden` : `Fertig! ${done} geladen${skipped ? `, ${skipped} übersprungen` : ''}${notFound ? `, ${notFound} nicht gefunden` : ''}`, }); } } catch (err) { console.error(`[${jobId}] Error:`, err.message); updateJob(jobId, { status: 'error', message: err.message }); } }); }); // POST /api/fix-artist-sort — batch-update sort tags + rename artist folder for all songs by a kanji artist app.post('/api/fix-artist-sort', async (req, res) => { const { ndUrl, user, pass, artistKanji, artistSort, jobId: clientJobId } = req.body; if (!ndUrl || !user || !pass || !artistKanji || !artistSort) return res.status(400).json({ error: 'ndUrl, user, pass, artistKanji, artistSort required' }); const jobId = clientJobId || ('job_' + Date.now()); jobs.set(jobId, { id: jobId, type: 'fix-sort', artist: artistKanji, title: `Romaji-Fix: ${artistSort}`, album: '', status: 'queued', progress: 0, message: 'Suche Songs…', log: [], createdAt: Date.now(), updatedAt: Date.now(), }); res.json({ jobId, status: 'queued' }); (async () => { const mkTok = () => { const s = Math.random().toString(36).slice(2, 10); return { s, t: crypto.createHash('md5').update(pass + s).digest('hex') }; }; // Search Navidrome for all songs by this artist let songs = []; try { const { s, t } = mkTok(); const r = await fetch( `${ndUrl}/rest/search3?query=${encodeURIComponent(artistKanji)}&artistCount=0&albumCount=0&songCount=500&u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=novasonic&f=json`, { signal: AbortSignal.timeout(15000) } ); const d = await r.json(); const all = d?.['subsonic-response']?.searchResult3?.song || []; songs = all.filter(sng => sng.artist === artistKanji || sng.albumArtist === artistKanji); } catch (e) { updateJob(jobId, { status: 'error', message: `Navidrome-Suche fehlgeschlagen: ${e.message}` }); return; } if (!songs.length) { updateJob(jobId, { status: 'done', progress: 100, message: 'Keine Songs gefunden' }); return; } log(jobId, `🔍 ${songs.length} Songs gefunden für "${artistKanji}"`); updateJob(jobId, { status: 'running', progress: 10, message: `${songs.length} Songs gefunden…` }); let done = 0, errors = 0; const errorDetails = []; const safeArtistDir = sanitize(artistKanji.split(/[,;&]/)[0].trim()); for (const ndSong of songs) { try { // Resolve file path let filePath = ndSong.path; if (!filePath) { errors++; continue; } if (!path.isAbsolute(filePath)) filePath = path.join(CONFIG.musicPath, filePath); if (!fs.existsSync(filePath)) { const fileName = path.basename(filePath); const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase(); const found = _rebasePath(filePath, CONFIG.musicPath) || _findFileRecursive(CONFIG.musicPath, fileName) || _findFileByStemOrRomaji(CONFIG.musicPath, titleStem); if (found) filePath = found; else { const detail = `Nicht gefunden: ${path.basename(ndSong.path || '')}`; log(jobId, `⚠ ${detail}`); errorDetails.push(detail); errors++; continue; } } const fileExt = path.extname(filePath); const tmpFile = filePath.slice(0, -fileExt.length) + '.sorttmp' + fileExt; // -map_metadata 0 preserves all existing tags; individual -metadata flags override only sort ones await new Promise((resolve, reject) => { const ff = spawn('ffmpeg', [ '-i', filePath, '-map', '0:a', '-map', '0:v?', '-c', 'copy', '-map_metadata', '0', '-metadata', `ARTISTSORT=${artistSort}`, '-metadata', `ALBUMARTISTSORT=${artistSort}`, tmpFile, '-y', '-loglevel', 'error', ]); let ffErr = ''; ff.stderr.on('data', d => ffErr += d.toString()); ff.on('error', reject); ff.on('close', code => code === 0 ? resolve() : reject(new Error(`ffmpeg exit ${code}: ${ffErr.slice(-200)}`))); }); fs.renameSync(tmpFile, filePath); // Rename the file itself: keep track prefix + title, replace artist segment. // Convention: {NN - }Artist - Title.ext (separator may be " - " or " – ") const oldBasename = path.basename(filePath); const oldStem = oldBasename.slice(0, -fileExt.length); // Extract leading track prefix (e.g. "01 - " or "01-15 – ") const trackPrefixMatch = oldStem.match(/^(\d[\d\-]*\s*[-–—]\s*)/); const trackPrefix = trackPrefixMatch ? trackPrefixMatch[1] : ''; // Everything after the track prefix: "OldArtist - Title" or just "Title" const afterPrefix = oldStem.slice(trackPrefix.length); // Split on first " - " or " – ": if 2+ segments, first is old artist, rest is title const segments = afterPrefix.split(/\s+[-–—]\s+/); const titlePart = segments.length >= 2 ? segments.slice(1).join(' - ') : afterPrefix; const safeArtistFile = sanitize(artistKanji.split(/[,;&]/)[0].trim()); const safeTitlePart = sanitize(titlePart); const newBasename = `${trackPrefix}${safeArtistFile} - ${safeTitlePart}${fileExt}`; if (newBasename !== oldBasename) { const renamedPath = path.join(path.dirname(filePath), newBasename); if (!fs.existsSync(renamedPath)) { fs.renameSync(filePath, renamedPath); filePath = renamedPath; } } // Move to correct artist/album folder: {musicPath}/{safeArtistDir}/{albumName}/ const oldAlbumDir = path.dirname(filePath); const oldArtistDir = path.dirname(oldAlbumDir); const albumName = path.basename(oldAlbumDir); const newAlbumDir = path.join(CONFIG.musicPath, safeArtistDir, albumName); if (newAlbumDir !== oldAlbumDir) { fs.mkdirSync(newAlbumDir, { recursive: true }); const destFile = path.join(newAlbumDir, path.basename(filePath)); if (!fs.existsSync(destFile)) fs.renameSync(filePath, destFile); // Clean up empty old dirs try { if (fs.readdirSync(oldAlbumDir).filter(f => !f.startsWith('.')).length === 0) { fs.rmdirSync(oldAlbumDir); if (fs.readdirSync(oldArtistDir).filter(f => !f.startsWith('.')).length === 0) fs.rmdirSync(oldArtistDir); } } catch {} } done++; updateJob(jobId, { progress: 10 + Math.round((done / songs.length) * 80), message: `${done}/${songs.length} Songs aktualisiert…` }); log(jobId, `✅ ${ndSong.title}`); } catch (e) { const detail = `${ndSong.title}: ${e.message}`; log(jobId, `❌ ${detail}`); errorDetails.push(detail); errors++; } } // Trigger Navidrome rescan try { const { s, t } = mkTok(); await fetch(`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=novasonic&f=json`, { signal: AbortSignal.timeout(5000) }); } catch {} const firstErr = errorDetails[0] ? ` (${errorDetails[0]})` : ''; const msg = errors > 0 ? `${done} aktualisiert, ${errors} Fehler${firstErr}` : `${done} Songs aktualisiert`; updateJob(jobId, { status: 'done', progress: 100, message: msg }); })().catch(e => updateJob(jobId, { status: 'error', message: e.message })); }); app.get('/api/status/:jobId', (req, res) => { const job = jobs.get(req.params.jobId); if (!job) return res.status(404).json({ error: 'Job not found' }); res.json(job); }); app.get('/api/jobs', (req, res) => { res.json(Array.from(jobs.values()).sort((a, b) => b.createdAt - a.createdAt).slice(0, 50)); }); // ─── SOUNDCLOUD RESOLVE ────────────────────────────────────────────────────── app.post('/api/soundcloud/resolve', async (req, res) => { const { url } = req.body; if (!url || !url.includes('soundcloud.com')) return res.status(400).json({ error: 'SoundCloud URL required' }); try { const { execFile } = require('child_process'); const data = await new Promise((resolve, reject) => { execFile( CONFIG.ytdlpBin, ['--flat-playlist', '--dump-single-json', '--no-warnings', url], { timeout: 30000, maxBuffer: 5 * 1024 * 1024 }, (err, stdout, stderr) => { if (err) return reject(new Error(`yt-dlp: ${stderr?.trim() || err.message}`)); try { resolve(JSON.parse(stdout.trim())); } catch { reject(new Error('yt-dlp: ungültige Ausgabe')); } } ); }); const isPlaylist = data._type === 'playlist' || Array.isArray(data.entries); if (isPlaylist) { const entries = data.entries || []; const tracks = entries.map(e => ({ trackName: e.title || '', artistName: e.uploader || e.artist || data.uploader || '', artworkUrl: e.thumbnail || '', duration: e.duration || 0, genre: e.genre || '', album: (e.album && !e.album.startsWith('[')) ? e.album : (e.title || ''), sourceUrl: e.url || e.webpage_url || '', })); res.json({ type: 'playlist', name: data.title || data.uploader || 'SoundCloud Playlist', artist: data.uploader || '', artworkUrl: data.thumbnail || tracks[0]?.artworkUrl || '', tracks, }); } else { // Filter yt-dlp placeholder values — treat them as empty const scAlbum = (data.album && !data.album.startsWith('[')) ? data.album : ''; const track = { trackName: data.title || '', artistName: data.uploader || data.artist || '', artworkUrl: data.thumbnail || '', duration: data.duration || 0, genre: data.genre || '', // Use track title as album for singles with no real album album: scAlbum || data.title || '', year: data.upload_date ? parseInt(data.upload_date.slice(0, 4), 10) : undefined, sourceUrl: url, }; res.json({ type: 'track', name: track.trackName, artist: track.artistName, artworkUrl: track.artworkUrl, tracks: [track], }); } } catch (e) { console.error('[soundcloud/resolve]', e.message); res.status(500).json({ error: e.message }); } }); // ─── MUSICBRAINZ LOOKUP ────────────────────────────────────────────────────── app.get('/api/musicbrainz/lookup', async (req, res) => { const { artist, title } = req.query; if (!artist || !title) return res.status(400).json({ error: 'artist and title required' }); try { // Use only the first artist — MB won't match "VXLLAIN, iGRES, ENXK" against "VXLLAIN feat. iGRES & ENXK" const firstArtist = artist.split(/[,;&•]/)[0].trim().replace(/"/g, ''); const query = `recording:"${title.replace(/"/g, '')}" AND artistname:"${firstArtist}"`; const url = `https://musicbrainz.org/ws/2/recording/?query=${encodeURIComponent(query)}&fmt=json&limit=5`; const r = await fetch(url, { headers: { 'User-Agent': 'NovaSonic/1.0 (me_honeyy@proton.me)' }, signal: AbortSignal.timeout(10000) }); if (!r.ok) return res.status(502).json({ error: `MusicBrainz ${r.status}` }); const data = await r.json(); const recordings = data.recordings || []; if (!recordings.length) return res.json({ found: false }); const rec = recordings[0]; const release = (rec.releases || [])[0]; const mbArtist = (rec['artist-credit'] || []).map(c => c.name || c.artist?.name).filter(Boolean).join('; '); const genre = (rec.genres || rec.tags || []).sort((a, b) => (b.count||0) - (a.count||0))[0]?.name || ''; const year = release?.date ? release.date.slice(0, 4) : ''; const trackNum = release?.media?.[0]?.['track-offset'] != null ? (release.media[0]['track-offset'] + 1) : undefined; const totalTracks = release?.media?.[0]?.['track-count'] || undefined; const discNum = release?.media?.[0]?.position || undefined; const coverUrl = release?.id ? `https://coverartarchive.org/release/${release.id}/front` : null; // album-artist from release artist-credit if present, else fall back to recording artist const mbAlbumArtist = (release?.['artist-credit'] || []).map(c => c.name || c.artist?.name).filter(Boolean).join('; ') || mbArtist; res.json({ found: true, title: rec.title || title, artist: mbArtist || artist, albumArtist: mbAlbumArtist || undefined, album: release?.title || '', year: year ? parseInt(year, 10) : undefined, trackNum: trackNum, totalTracks: totalTracks, discNum: discNum, genre: genre, coverUrl: coverUrl, mbid: rec.id, }); } catch (e) { console.error('[musicbrainz/lookup]', e.message); res.status(500).json({ error: e.message }); } }); // ─── SORT TAGS READ ────────────────────────────────────────────────────────── app.get('/api/song-sorttags', async (req, res) => { const { ndUrl, user, pass, songId } = req.query; if (!ndUrl || !user || !pass || !songId) return res.status(400).json({ error: 'ndUrl, user, pass, songId required' }); let filePath; try { const salt = Math.random().toString(36).slice(2, 10); const token = crypto.createHash('md5').update(pass + salt).digest('hex'); const ndRes = await fetch(`${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json`, { signal: AbortSignal.timeout(8000) }); const nd = await ndRes.json(); filePath = nd?.['subsonic-response']?.song?.path; if (!filePath) return res.status(404).json({ error: 'Song not found' }); } catch (e) { return res.status(502).json({ error: e.message }); } if (!path.isAbsolute(filePath)) filePath = path.join(CONFIG.musicPath, filePath); if (!fs.existsSync(filePath)) { const fileName = path.basename(filePath); const rebased = _rebasePath(filePath, CONFIG.musicPath); const rebasedDir = rebased ? path.dirname(rebased) : null; const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase(); const found = rebased || _findFileRecursive(CONFIG.musicPath, fileName) || _findFileInDir(rebasedDir || path.dirname(filePath), fileName) || _findFileByStemOrRomaji(CONFIG.musicPath, titleStem); if (found) filePath = found; else return res.status(404).json({ error: 'File not found' }); } try { const out = await new Promise((resolve, reject) => { const proc = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_entries', 'format_tags', filePath]); let buf = ''; proc.stdout.on('data', d => buf += d); proc.on('error', reject); proc.on('close', () => { try { resolve(JSON.parse(buf)); } catch { resolve({}); } }); }); const tags = out?.format?.tags || {}; const findTag = (...keys) => { for (const k of keys) { const v = tags[k] || tags[k.toLowerCase()] || tags[k.toUpperCase()]; if (v) return v; } return ''; }; res.json({ titleSort: findTag('TITLESORT', 'titlesort'), artistSort: findTag('ARTISTSORT', 'artistsort'), albumArtistSort: findTag('ALBUMARTISTSORT', 'albumartistsort', 'TSO2'), albumSort: findTag('ALBUMSORT', 'albumsort'), }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ─── PLAYLIST MIGRATION HELPERS ────────────────────────────────────────────── // Used to preserve playlist membership when a file is renamed/moved during edit. async function _ndApi(ndUrl, user, pass, method, extra = '') { const s = Math.random().toString(36).slice(2, 10); const t = crypto.createHash('md5').update(pass + s).digest('hex'); const r = await fetch( `${ndUrl}/rest/${method}?u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=novasonic&f=json${extra}`, { signal: AbortSignal.timeout(8000) } ); return r.json(); } async function _playlistsContaining(ndUrl, user, pass, songId) { try { const d = await _ndApi(ndUrl, user, pass, 'getPlaylists'); const pls = [].concat(d?.['subsonic-response']?.playlists?.playlist || []); const affected = []; for (const pl of pls) { const det = await _ndApi(ndUrl, user, pass, 'getPlaylist', `&id=${pl.id}`); const entries = [].concat(det?.['subsonic-response']?.playlist?.entry || []); if (entries.some(e => e.id === songId)) affected.push(pl.id); } return affected; } catch { return []; } } // Poll Navidrome until the renamed song appears (up to ~5s) async function _findSongAfterRename(ndUrl, user, pass, title, artist, oldId) { for (let i = 0; i < 5; i++) { if (i > 0) await new Promise(r => setTimeout(r, 1200)); try { const q = encodeURIComponent(`${artist} ${title}`.trim().slice(0, 100)); const d = await _ndApi(ndUrl, user, pass, 'search3', `&query=${q}&songCount=20&artistCount=0&albumCount=0`); const songs = [].concat(d?.['subsonic-response']?.searchResult3?.song || []); const match = songs.find(s => s.id !== oldId && (s.title || '').toLowerCase() === (title || '').toLowerCase() ); if (match) return match; } catch {} } return null; } async function _migratePlaylists(ndUrl, user, pass, plIds, oldId, newId) { for (const plId of plIds) { try { const det = await _ndApi(ndUrl, user, pass, 'getPlaylist', `&id=${plId}`); const entries = [].concat(det?.['subsonic-response']?.playlist?.entry || []); const idx = entries.findIndex(e => e.id === oldId); let extra = `&songIdToAdd=${newId}`; if (idx >= 0) extra += `&songIndexToRemove=${idx}`; await _ndApi(ndUrl, user, pass, 'updatePlaylist', `&playlistId=${plId}${extra}`); } catch {} } } // ─── METADATA EDIT ─────────────────────────────────────────────────────────── app.post('/api/edit-metadata', async (req, res) => { const { ndUrl, user, pass, songId, title, artist, albumArtist, album, year, trackNum, totalTracks, discNum, discTotal, genre, artworkBase64, artworkMime, artworkUrl: newArtworkUrl, titleSort, artistSort, albumArtistSort, albumSort } = req.body; if (!ndUrl || !user || !pass || !songId) return res.status(400).json({ error: 'ndUrl, user, pass, songId required' }); // Normalize artist separators — accept •, ·, ;, , and rejoin canonically const normalArtist = _normalizeArtists(artist, ';'); const normalAlbumArtist = _normalizeArtists(albumArtist, ', '); // Convert Japanese names to romaji for storage const romajiArtist = normalArtist ? romajiForStorage(normalArtist) || normalArtist : normalArtist; const romajiAlbumArtist = normalAlbumArtist ? romajiForStorage(normalAlbumArtist) || normalAlbumArtist : normalAlbumArtist; const romajiTitle = title ? romajiForStorage(title) || title : title; const romajiAlbum = album ? romajiForStorage(album) || album : album; // Resolve file path from Navidrome let filePath; let ndSong; try { const salt = Math.random().toString(36).slice(2, 10); const token = crypto.createHash('md5').update(pass + salt).digest('hex'); const ndApi = `${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json`; const ndRes = await fetch(ndApi, { signal: AbortSignal.timeout(8000) }); const ndData = await ndRes.json(); ndSong = ndData?.['subsonic-response']?.song; filePath = ndSong?.path; if (!filePath) return res.status(404).json({ error: 'Song not found in Navidrome' }); } catch (e) { return res.status(502).json({ error: `Navidrome lookup failed: ${e.message}` }); } // Collect playlists containing this song BEFORE the edit so we can restore membership // if the file gets renamed/moved (which changes the Navidrome song ID). const affectedPlaylists = await _playlistsContaining(ndUrl, user, pass, songId).catch(() => []); // If not absolute, try relative to music root if (!path.isAbsolute(filePath)) filePath = path.join(CONFIG.musicPath, filePath); // Fallback: Navidrome may return a path from a different container mount. // 1. Rebase: strip leading path components and rejoin under CONFIG.musicPath. // 2. Exact recursive search under CONFIG.musicPath. // 3. Fuzzy dir search: same dir, matching track-number prefix or title stem. if (!fs.existsSync(filePath)) { const rebased = _rebasePath(filePath, CONFIG.musicPath); const fileName = path.basename(filePath); const rebasedDir = rebased ? path.dirname(rebased) : null; // Also check the original dir — the file may have been renamed (e.g. artist added to filename) // but Navidrome's index is stale and still points to the old name. const originalDir = path.dirname(filePath); const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase(); const found = rebased || _findFileRecursive(CONFIG.musicPath, fileName) || _findFileInDir(rebasedDir || originalDir, fileName) || _findFileByStemOrRomaji(CONFIG.musicPath, titleStem) // Last resort: Subsonic getSong returns a *virtual* (tag-built) path, so the folder // can be "[Unknown Album]" while the file actually sits in "Singles" with an artist // prefix. findExistingTrack matches by normalized artist+title across all album folders. || findExistingTrack(CONFIG.musicPath, ndSong.albumArtist || ndSong.artist || '', ndSong.title || ''); if (found) { filePath = found; } else return res.status(404).json({ error: `File not found: ${filePath} (music root: ${CONFIG.musicPath})` }); } // Resolve artwork: base64 > URL let artFile = null; try { if (artworkBase64) { artFile = filePath + '.arttmp'; const mime = artworkMime || 'image/jpeg'; const ext = mime.includes('png') ? 'png' : 'jpg'; artFile = filePath + `.arttmp.${ext}`; fs.writeFileSync(artFile, Buffer.from(artworkBase64, 'base64')); } else if (newArtworkUrl) { artFile = filePath + '.arttmp.jpg'; // Download artwork URL (may redirect — use fetch which follows redirects) const artRes = await fetch(newArtworkUrl, { signal: AbortSignal.timeout(15000) }); if (artRes.ok) { const buf = Buffer.from(await artRes.arrayBuffer()); fs.writeFileSync(artFile, buf); } else { artFile = null; } } } catch { artFile = null; } const fileExt = path.extname(filePath); const tmpFile = filePath.slice(0, -fileExt.length) + '.edittmp' + fileExt; try { await new Promise((resolve, reject) => { const ffArgs = ['-i', filePath]; if (artFile) ffArgs.push('-i', artFile); ffArgs.push('-map', '0:a'); // Always carry cover art: new file if provided, otherwise preserve existing embedded art if (artFile) { ffArgs.push('-map', '1:v'); } else { ffArgs.push('-map', '0:v?'); // '?' = optional, no error if file has no cover } ffArgs.push('-c', 'copy', '-map_metadata', '-1', '-id3v2_version', '3'); // Main tags: store original user input (may be Japanese — Apple Music / other apps see authentic text) if (title) ffArgs.push('-metadata', `title=${title}`); if (normalArtist) ffArgs.push('-metadata', `artist=${normalArtist}`); const resolvedAlbumArtist = normalAlbumArtist || (normalArtist ? normalArtist.split(';')[0].trim() : null); if (resolvedAlbumArtist) ffArgs.push('-metadata', `album_artist=${resolvedAlbumArtist}`); if (album) ffArgs.push('-metadata', `album=${album}`); // Sort tags: manual override from frontend > auto-computed romaji const effectiveTitleSort = titleSort !== undefined ? titleSort : (romajiTitle !== title ? romajiTitle : null); const effectiveArtistSort = artistSort !== undefined ? artistSort : (romajiArtist !== normalArtist ? romajiArtist : null); const resolvedRomajiAA = romajiAlbumArtist || (romajiArtist ? romajiArtist.split(';')[0].trim() : null); const effectiveAlbumArtistSort = albumArtistSort !== undefined ? albumArtistSort : (resolvedRomajiAA !== resolvedAlbumArtist ? resolvedRomajiAA : null); const effectiveAlbumSort = albumSort !== undefined ? albumSort : (romajiAlbum !== album ? romajiAlbum : null); if (effectiveTitleSort) ffArgs.push('-metadata', `TITLESORT=${effectiveTitleSort}`); if (effectiveArtistSort) ffArgs.push('-metadata', `ARTISTSORT=${effectiveArtistSort}`); if (effectiveAlbumArtistSort) ffArgs.push('-metadata', `ALBUMARTISTSORT=${effectiveAlbumArtistSort}`); if (effectiveAlbumSort) ffArgs.push('-metadata', `ALBUMSORT=${effectiveAlbumSort}`); if (year) ffArgs.push('-metadata', `date=${year}`); if (trackNum) ffArgs.push('-metadata', `track=${totalTracks ? `${trackNum}/${totalTracks}` : String(trackNum)}`); if (totalTracks) ffArgs.push('-metadata', `totaltracks=${String(totalTracks)}`); if (discNum) ffArgs.push('-metadata', `disc=${discTotal ? `${discNum}/${discTotal}` : String(discNum)}`); if (genre) ffArgs.push('-metadata', `genre=${genre}`); ffArgs.push('-metadata:s:v', 'title=Album cover', '-metadata:s:v', 'comment=Cover (front)'); ffArgs.push(tmpFile, '-y', '-loglevel', 'error'); const ff = spawn('ffmpeg', ffArgs); let ffErr = ''; ff.stderr.on('data', d => ffErr += d.toString()); ff.on('error', reject); ff.on('close', code => { if (code === 0 && fs.existsSync(tmpFile)) resolve(); else reject(new Error(`ffmpeg exit ${code}: ${ffErr.slice(-300)}`)); }); }); fs.renameSync(tmpFile, filePath); if (artFile) try { fs.unlinkSync(artFile); } catch {} // ── Post-save: rename file and/or move to correct artist/album folder ── let currentFile = filePath; // 1. Rename file if title changed — convention: {01 - }?{Artist} - {Title}.ext // Use original names (may be Japanese); romaji goes only into sort tags. if (title) { const safeTitle = sanitize(title); const fileArtist = (normalArtist || ndSong?.artist || '').replace(/;/g, ','); const safeArtistPart = fileArtist ? sanitize(fileArtist) + ' - ' : ''; const prefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : ''; const newName = `${prefix}${safeArtistPart}${safeTitle}${fileExt}`; const newPath = path.join(path.dirname(currentFile), newName); if (newPath !== currentFile && !fs.existsSync(newPath)) { try { fs.renameSync(currentFile, newPath); currentFile = newPath; } catch (e) { console.error('[edit-metadata] file rename:', e.message); } } } // 2. Move to new artist/album folder when albumArtist or album changed const oldAlbumDir = path.dirname(currentFile); const oldArtistDir = path.dirname(oldAlbumDir); const newAlbumName = album ? sanitize(album) : path.basename(oldAlbumDir); const newArtistName = normalAlbumArtist ? sanitize(normalAlbumArtist.split(/[,;&]/)[0].trim()) : path.basename(oldArtistDir); const newDir = path.join(CONFIG.musicPath, newArtistName, newAlbumName); if (newDir !== oldAlbumDir) { try { fs.mkdirSync(newDir, { recursive: true }); const destFile = path.join(newDir, path.basename(currentFile)); if (!fs.existsSync(destFile)) { fs.renameSync(currentFile, destFile); currentFile = destFile; } // Remove old album dir if now empty, then old artist dir if also empty const oldAlbumLeft = fs.readdirSync(oldAlbumDir).filter(f => !f.startsWith('.')); if (oldAlbumLeft.length === 0) { try { fs.rmdirSync(oldAlbumDir); } catch {} try { const oldArtistLeft = fs.readdirSync(oldArtistDir).filter(f => !f.startsWith('.')); if (oldArtistLeft.length === 0) fs.rmdirSync(oldArtistDir); } catch {} } } catch (e) { console.error('[edit-metadata] move:', e.message); } } else if (album && path.basename(oldAlbumDir) !== sanitize(album)) { // Only album name changed (no artist move) — rename album folder in place const renamedDir = path.join(oldArtistDir, sanitize(album)); if (!fs.existsSync(renamedDir)) { try { fs.renameSync(oldAlbumDir, renamedDir); } catch (e) { console.error('[edit-metadata] folder rename:', e.message); } } } // Trigger Navidrome rescan try { const salt2 = Math.random().toString(36).slice(2, 10); const tok2 = crypto.createHash('md5').update(pass + salt2).digest('hex'); await fetch(`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${tok2}&s=${salt2}&v=1.16.1&c=novasonic&f=json`, { signal: AbortSignal.timeout(5000) }); } catch {} // Restore playlist membership after rename/move — the rescan gives the renamed song a new ID if (affectedPlaylists.length > 0) { const effectiveTitle = title || ndSong?.title || ''; const effectiveArtist = (artist || ndSong?.artist || '').split(';')[0].trim(); const newSong = await _findSongAfterRename(ndUrl, user, pass, effectiveTitle, effectiveArtist, songId); if (newSong && newSong.id !== songId) { await _migratePlaylists(ndUrl, user, pass, affectedPlaylists, songId, newSong.id); } } res.json({ ok: true }); } catch (e) { try { if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); } catch {} if (artFile) try { fs.unlinkSync(artFile); } catch {} console.error('[edit-metadata]', e.message); res.status(500).json({ error: e.message }); } }); // ─── SONG DELETE ───────────────────────────────────────────────────────────── // Deletes a song file from disk, then prunes empty album/artist folders. // Folder pruning is strictly depth-limited: only the direct album parent and its // artist parent are ever touched — no recursive delete, no removing the music root. app.post('/api/delete-song', async (req, res) => { const { ndUrl, user, pass, songId } = req.body; if (!ndUrl || !user || !pass || !songId) return res.status(400).json({ error: 'ndUrl, user, pass, songId required' }); // Resolve the file path from Navidrome let filePath; try { const salt = Math.random().toString(36).slice(2, 10); const token = crypto.createHash('md5').update(pass + salt).digest('hex'); const ndRes = await fetch( `${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json`, { signal: AbortSignal.timeout(8000) } ); const ndData = await ndRes.json(); filePath = ndData?.['subsonic-response']?.song?.path; if (!filePath) return res.status(404).json({ error: 'Song not found in Navidrome' }); } catch (e) { return res.status(502).json({ error: `Navidrome lookup failed: ${e.message}` }); } const ndPath = filePath; // path exactly as Navidrome reported it (for diagnostics) if (!path.isAbsolute(filePath)) filePath = path.join(CONFIG.musicPath, filePath); if (!fs.existsSync(filePath)) { const fileName = path.basename(filePath); const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase(); const found = _rebasePath(filePath, CONFIG.musicPath) || _findFileRecursive(CONFIG.musicPath, fileName) || _findFileInDir(path.dirname(filePath), fileName) || _findFileByStemOrRomaji(CONFIG.musicPath, titleStem); if (found) filePath = found; else return res.status(404).json({ error: `File not found on disk. Navidrome path: "${ndPath}" — music root: "${CONFIG.musicPath}" — searched for: "${fileName}"`, }); } // Safety: file must be inside the music root and must be an audio file const realFile = path.resolve(filePath); const realRoot = path.resolve(CONFIG.musicPath); const rootSlash = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep; if (!realFile.startsWith(rootSlash)) return res.status(403).json({ error: 'Path outside music root — refusing to delete' }); if (!AUDIO_EXT_RE.test(path.extname(realFile))) return res.status(400).json({ error: 'Target is not a recognised audio file' }); const albumDir = path.dirname(realFile); const artistDir = path.dirname(albumDir); // Delete the file try { fs.unlinkSync(realFile); } catch (e) { return res.status(500).json({ error: `Could not delete file: ${e.message}` }); } // Prune empty album folder — only if it is a direct child of the music root hierarchy // (not the root itself) and only when truly empty (no hidden files either) if (albumDir !== realRoot && albumDir.startsWith(rootSlash)) { try { const albumLeft = fs.readdirSync(albumDir).filter(f => !f.startsWith('.')); if (albumLeft.length === 0) { fs.rmdirSync(albumDir); // throws if not empty — extra safety net // Prune empty artist folder only if it too is directly inside the music root if (artistDir !== realRoot && artistDir.startsWith(rootSlash)) { try { const artistLeft = fs.readdirSync(artistDir).filter(f => !f.startsWith('.')); if (artistLeft.length === 0) fs.rmdirSync(artistDir); } catch {} } } } catch {} } // Trigger Navidrome rescan so the deleted song disappears from the UI try { const s2 = Math.random().toString(36).slice(2, 10); const t2 = crypto.createHash('md5').update(pass + s2).digest('hex'); await fetch( `${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t2}&s=${s2}&v=1.16.1&c=novasonic&f=json`, { signal: AbortSignal.timeout(5000) } ); } catch {} res.json({ ok: true }); }); // ─── ABS PROXY ─────────────────────────────────────────────────────────────── // Forwards API calls to Audiobookshelf to avoid CORS restrictions in the browser. // The browser sends X-ABS-URL and X-ABS-Token headers; audio streaming uses // direct