Gitea baut browser_download_url aus seiner eigenen ROOT_URL (hier 192.168.178.153:3000), die vom Backend-Container nicht erreichbar ist. Der API-Call gelang, der Asset-Download nicht — der stille Fallback auf die nicht vorhandene lokale Datei meldete dann "Keine APK hinterlegt". Asset- und Redirect-URLs werden jetzt auf GITEA_URL umgeschrieben; scheitert der Weg trotzdem, sagen Log und Fehlermeldung welcher der beiden Wege fehlt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1686 lines
83 KiB
JavaScript
1686 lines
83 KiB
JavaScript
/**
|
||
* 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 http = require('http');
|
||
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 });
|
||
});
|
||
|
||
// ─── ARTIST OVERRIDES (pin a local artist → a specific Deezer artist; shared) ──
|
||
// Fixes same-name collisions where NovaSonic would otherwise guess the wrong Deezer artist
|
||
// (fans / top songs / albums / image). Keyed by the Navidrome artist id.
|
||
const _artistOverridesFile = path.join(CONFIG.dataPath, 'deezer-artist-overrides.json');
|
||
function _loadArtistOverrides() {
|
||
try { return JSON.parse(fs.readFileSync(_artistOverridesFile, 'utf8')); } catch { return {}; }
|
||
}
|
||
function _saveArtistOverrides(map) {
|
||
try {
|
||
fs.mkdirSync(CONFIG.dataPath, { recursive: true });
|
||
fs.writeFileSync(_artistOverridesFile, JSON.stringify(map, null, 2), 'utf8');
|
||
} catch (e) { console.error('Failed to save artist overrides:', e.message); }
|
||
}
|
||
|
||
// GET — full map { ndArtistId: { deezerArtistId, name, image, fanCount } }
|
||
app.get('/api/artist-overrides', (_req, res) => res.json(_loadArtistOverrides()));
|
||
|
||
// POST — upsert a pin { ndArtistId, deezerArtistId, name, image, fanCount }
|
||
app.post('/api/artist-overrides', (req, res) => {
|
||
const { ndArtistId, deezerArtistId, name, image, fanCount } = req.body || {};
|
||
if (!ndArtistId || !deezerArtistId) return res.status(400).json({ error: 'ndArtistId and deezerArtistId required' });
|
||
const map = _loadArtistOverrides();
|
||
map[String(ndArtistId)] = {
|
||
deezerArtistId: String(deezerArtistId),
|
||
name: name || '',
|
||
image: image || '',
|
||
fanCount: (fanCount ?? null),
|
||
};
|
||
_saveArtistOverrides(map);
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
// DELETE /api/artist-overrides/:ndArtistId — remove a pin
|
||
app.delete('/api/artist-overrides/:ndArtistId', (req, res) => {
|
||
const id = decodeURIComponent(req.params.ndArtistId);
|
||
const map = _loadArtistOverrides();
|
||
delete map[id];
|
||
_saveArtistOverrides(map);
|
||
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 }));
|
||
});
|
||
|
||
// Server-side events cache. Ticketmaster's free `keyword` search is flaky — for the same artist
|
||
// it intermittently returns a partial subset (or []), so back-to-back calls disagree (web showed 7,
|
||
// a later live call got only 2). Caching on the BACKEND (not per-client) keeps every client
|
||
// consistent, and we PREFER THE LARGER recent result: a transient partial/empty response never
|
||
// shrinks a good list. TTL bounds staleness (a genuinely-dropped show self-heals within an hour).
|
||
const _eventsCache = new Map(); // artistLower → { ts, data }
|
||
const _EVENTS_TTL = 60 * 60 * 1000; // 1h
|
||
|
||
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 cacheKey = artist.toLowerCase();
|
||
const cached = _eventsCache.get(cacheKey);
|
||
const fresh = cached && (Date.now() - cached.ts < _EVENTS_TTL);
|
||
|
||
// Serve the cached list, but only when it wouldn't hide a better live result. When we fall back
|
||
// to cache on a TM error/partial, we DON'T bump ts — so the cache still expires on schedule and
|
||
// self-heals instead of pinning stale events forever.
|
||
const respondWithCacheFallback = (liveEvents) => {
|
||
if (fresh && cached.data.length > liveEvents.length) {
|
||
console.log(`[events] ${artist}: live=${liveEvents.length} < cache=${cached.data.length} → serving cache`);
|
||
return res.json(cached.data);
|
||
}
|
||
if (liveEvents.length > 0 || !fresh) {
|
||
_eventsCache.set(cacheKey, { ts: Date.now(), data: liveEvents });
|
||
return res.json(liveEvents);
|
||
}
|
||
// live empty but cache still fresh → serve cache
|
||
return res.json(cached.data);
|
||
};
|
||
|
||
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 respondWithCacheFallback([]);
|
||
}
|
||
const raw = data?._embedded?.events || [];
|
||
const events = raw.map(e => {
|
||
const venue = e._embedded?.venues?.[0] || {};
|
||
const start = e.dates?.start || {};
|
||
// Prefer the venue-LOCAL date/time (localDate + localTime) over the UTC `dateTime` —
|
||
// otherwise a 20:00 local show is shown as its 08:00Z UTC instant. Sent as a naive local
|
||
// datetime string (no zone); the clients render it as-is.
|
||
const dt = (start.localDate
|
||
? `${start.localDate}T${start.localTime || '00:00:00'}`
|
||
: start.dateTime) || 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 (live)`);
|
||
respondWithCacheFallback(events);
|
||
} catch(err) { console.error('[events] parse error:', err); respondWithCacheFallback([]); }
|
||
});
|
||
}).on('error', e => { console.error('[events] request error:', e.message); respondWithCacheFallback([]); });
|
||
});
|
||
|
||
// 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 }));
|
||
});
|
||
|
||
// ── NovaSonic Android APK distribution ──────────────────────────────────────
|
||
// The web app offers a "download the Android app" button that points here, so co-users always get
|
||
// the latest APK without the maintainer hand-delivering it. Two sources, tried in order:
|
||
// 1) A FIXED Gitea release (default tag "apk-latest") whose .apk asset you overwrite each build —
|
||
// constant ~30 MB, uploadable from anywhere. The BACKEND fetches it server-side (same LAN as
|
||
// Gitea), so Gitea can stay internal-only and the token never reaches the browser.
|
||
// env: GITEA_URL (internal base, e.g. http://gitea:3000), GITEA_REPO (Docker/NovaSonic),
|
||
// GITEA_TOKEN (read token; only needed for a private repo), GITEA_APK_TAG (default apk-latest)
|
||
// 2) Fallback: a local file in the writable data volume:
|
||
// docker cp app-release.apk novasonic-backend:/data/novasonic.apk
|
||
// (optional /data/novasonic.version.txt with a version string)
|
||
const APK_PATH = process.env.APK_PATH || path.join(CONFIG.dataPath, 'novasonic.apk');
|
||
const APK_VER_PATH = path.join(CONFIG.dataPath, 'novasonic.version.txt');
|
||
const GITEA_URL = (process.env.GITEA_URL || '').replace(/\/$/, '');
|
||
const GITEA_REPO = process.env.GITEA_REPO || '';
|
||
const GITEA_TOKEN = process.env.GITEA_TOKEN || '';
|
||
const GITEA_APK_TAG = process.env.GITEA_APK_TAG || 'apk-latest';
|
||
|
||
function _giteaApiJson(pathAndQuery) {
|
||
return new Promise((resolve, reject) => {
|
||
if (!GITEA_URL || !GITEA_REPO) return reject(new Error('gitea not configured'));
|
||
const u = new URL(`${GITEA_URL}/api/v1/repos/${GITEA_REPO}${pathAndQuery}`);
|
||
const lib = u.protocol === 'https:' ? https : http;
|
||
const headers = { 'Accept': 'application/json', 'User-Agent': 'NovaSonic' };
|
||
if (GITEA_TOKEN) headers['Authorization'] = `token ${GITEA_TOKEN}`;
|
||
lib.get(u, { headers }, r => {
|
||
let body = '';
|
||
r.on('data', d => body += d);
|
||
r.on('end', () => {
|
||
if (r.statusCode < 200 || r.statusCode >= 300) return reject(new Error(`gitea ${r.statusCode}`));
|
||
try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
|
||
});
|
||
}).on('error', reject);
|
||
});
|
||
}
|
||
|
||
// Gitea baut seine browser_download_url aus der eigenen ROOT_URL — die kann eine ganz andere Adresse
|
||
// sein als die, unter der das Backend Gitea erreicht (interne IP vs. Reverse-Proxy-Domain). Deshalb
|
||
// Schema+Host+Port immer durch GITEA_URL ersetzen und nur den Pfad übernehmen.
|
||
function _viaGiteaHost(urlStr) {
|
||
if (!urlStr || !GITEA_URL) return urlStr;
|
||
try {
|
||
const base = new URL(GITEA_URL);
|
||
const u = new URL(urlStr);
|
||
if (u.origin === base.origin) return urlStr;
|
||
return new URL(u.pathname + u.search, base).toString();
|
||
} catch { return urlStr; }
|
||
}
|
||
|
||
// The .apk asset of the fixed release, or null if unavailable.
|
||
async function _giteaApkAsset() {
|
||
const rel = await _giteaApiJson(`/releases/tags/${encodeURIComponent(GITEA_APK_TAG)}`);
|
||
const asset = (rel.assets || []).find(a => /\.apk$/i.test(a.name || ''));
|
||
if (!asset) return null;
|
||
// The publish script writes "versionCode=NN" into the release body so the in-app updater has a
|
||
// reliable integer to compare against BuildConfig.VERSION_CODE.
|
||
const vcMatch = /versionCode\s*[=:]\s*(\d+)/i.exec(rel.body || '');
|
||
return {
|
||
size: asset.size,
|
||
url: _viaGiteaHost(asset.browser_download_url),
|
||
version: rel.name || rel.tag_name || null,
|
||
versionCode: vcMatch ? parseInt(vcMatch[1], 10) : null,
|
||
updatedAt: new Date(asset.created_at || rel.published_at || Date.now()).getTime(),
|
||
};
|
||
}
|
||
|
||
function _serveLocalApk(res) {
|
||
if (res.headersSent) return;
|
||
fs.stat(APK_PATH, (err) => {
|
||
// Ohne lokale Datei UND ohne erreichbares Gitea-Asset: sagen, welcher der beiden Wege fehlt,
|
||
// sonst sucht man die APK an der falschen Stelle (die Logs oben nennen den Grund).
|
||
if (err) return res.status(404).json({
|
||
error: (GITEA_URL && GITEA_REPO)
|
||
? `APK-Release nicht erreichbar (${GITEA_URL}, Tag ${GITEA_APK_TAG}) und keine lokale Datei unter ${APK_PATH}`
|
||
: 'Keine APK hinterlegt',
|
||
});
|
||
res.setHeader('Content-Type', 'application/vnd.android.package-archive');
|
||
res.setHeader('Content-Disposition', 'attachment; filename="NovaSonic.apk"');
|
||
res.sendFile(APK_PATH);
|
||
});
|
||
}
|
||
|
||
// Stream a Gitea attachment to the client, following redirects, falling back to the local file.
|
||
function _streamApkFromUrl(urlStr, res, redirectsLeft = 3) {
|
||
const u = new URL(urlStr);
|
||
const lib = u.protocol === 'https:' ? https : http;
|
||
const headers = { 'User-Agent': 'NovaSonic' };
|
||
if (GITEA_TOKEN) headers['Authorization'] = `token ${GITEA_TOKEN}`;
|
||
lib.get(u, { headers }, up => {
|
||
if ([301, 302, 303, 307, 308].includes(up.statusCode) && up.headers.location && redirectsLeft > 0) {
|
||
up.resume();
|
||
return _streamApkFromUrl(_viaGiteaHost(new URL(up.headers.location, u).toString()), res, redirectsLeft - 1);
|
||
}
|
||
if (up.statusCode !== 200) {
|
||
up.resume();
|
||
console.warn(`[apk] Gitea-Asset lieferte HTTP ${up.statusCode} (${u.origin}) — fallback auf ${APK_PATH}`);
|
||
return _serveLocalApk(res);
|
||
}
|
||
res.setHeader('Content-Type', 'application/vnd.android.package-archive');
|
||
res.setHeader('Content-Disposition', 'attachment; filename="NovaSonic.apk"');
|
||
if (up.headers['content-length']) res.setHeader('Content-Length', up.headers['content-length']);
|
||
up.pipe(res);
|
||
}).on('error', (e) => {
|
||
console.warn(`[apk] Gitea-Asset nicht erreichbar (${u.origin}): ${e.message} — fallback auf ${APK_PATH}`);
|
||
_serveLocalApk(res);
|
||
});
|
||
}
|
||
|
||
app.get('/api/apk/info', async (req, res) => {
|
||
try {
|
||
const a = await _giteaApkAsset();
|
||
if (a) return res.json({ available: true, size: a.size, updatedAt: a.updatedAt, version: a.version, versionCode: a.versionCode, source: 'gitea' });
|
||
} catch { /* fall through to local */ }
|
||
fs.stat(APK_PATH, (err, st) => {
|
||
if (err) return res.json({ available: false });
|
||
let version = null, versionCode = null;
|
||
try { version = fs.readFileSync(APK_VER_PATH, 'utf8').trim() || null; } catch {}
|
||
try { versionCode = parseInt(fs.readFileSync(path.join(CONFIG.dataPath, 'novasonic.versioncode.txt'), 'utf8').trim(), 10) || null; } catch {}
|
||
res.json({ available: true, size: st.size, updatedAt: st.mtimeMs, version, versionCode, source: 'local' });
|
||
});
|
||
});
|
||
|
||
app.get('/api/apk', async (req, res) => {
|
||
let asset = null;
|
||
try { asset = await _giteaApkAsset(); } catch { /* fall through to local */ }
|
||
if (asset?.url) return _streamApkFromUrl(asset.url, res);
|
||
_serveLocalApk(res);
|
||
});
|
||
|
||
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 if (result?.failed) {
|
||
updateJob(jobId, { status: 'error', message: 'Download fehlgeschlagen — YouTube hat den Abruf blockiert (403).' });
|
||
} 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 <audio src> URLs and is unaffected by CORS.
|
||
app.all('/api/abs-proxy/*path', async (req, res) => {
|
||
const absBase = req.headers['x-abs-url'];
|
||
const absToken = req.headers['x-abs-token'];
|
||
if (!absBase || !absToken) { res.status(400).json({ error: 'Missing X-ABS-URL or X-ABS-Token' }); return; }
|
||
|
||
// Strip the proxy prefix to get the ABS path (e.g. /api/me, /api/libraries/…)
|
||
const absPath = req.path.replace(/^\/api\/abs-proxy/, '');
|
||
const qs = Object.keys(req.query).length ? '?' + new URLSearchParams(req.query).toString() : '';
|
||
const url = `${absBase}${absPath}${qs}`;
|
||
|
||
const fwdHeaders = { 'Authorization': `Bearer ${absToken}`, 'Content-Type': 'application/json' };
|
||
const opts = { method: req.method, headers: fwdHeaders };
|
||
if (req.method !== 'GET' && req.method !== 'HEAD' && req.body && Object.keys(req.body).length) {
|
||
opts.body = JSON.stringify(req.body);
|
||
}
|
||
|
||
try {
|
||
const upstream = await fetch(url, opts);
|
||
const text = await upstream.text();
|
||
res.status(upstream.status)
|
||
.setHeader('Content-Type', upstream.headers.get('Content-Type') || 'application/json')
|
||
.send(text);
|
||
} catch (e) {
|
||
res.status(502).json({ error: `ABS proxy error: ${e.message}` });
|
||
}
|
||
});
|
||
|
||
// ─── START ────────────────────────────────────────────────────────────────────
|
||
app.listen(CONFIG.port, () => {
|
||
console.log(`\n🎵 NovaSonic Backend running on port ${CONFIG.port}`);
|
||
console.log(` Music folder : ${CONFIG.musicPath}`);
|
||
console.log(` Audio format : ${CONFIG.audioFmt} (quality ${CONFIG.audioQuality})`);
|
||
console.log(` yt-dlp : ${ytdlpVersion}`);
|
||
console.log('');
|
||
});
|