Files
NovaSonic/backend/novasonic-server.js
T
marcandClaude Opus 5 cb5ea26c77 fix: Maus-Zurück im PWA-Fenster, sprechende Player-Namen, Pfad-Etikett
Im installierten Fenster ("display": standalone) gibt es keine Zurück-/
Vorwärts-Knöpfe, und die Maus-Seitentasten werden nicht auf den Verlauf
umgelenkt — im Tab ging es, im Fenster nicht. Die App übernimmt X1/X2
jetzt selbst, aber nur im Standalone-Fenster (im Tab würde das doppelt
navigieren; beide Fälle im Test abgedeckt). Zurück läuft über _navGoBack,
schließt also erst ein offenes Now-Playing.

Backend meldet sich jetzt als NovaSonic-Backend, der Scrobble-Dienst als
NovaSonic-Scrobble. Vorher novasonic vs. NovaSonic — in Navidromes
Player-Liste (Name = "client [User-Agent]") zwei Zeilen "[node]", die
sich nur an einem Großbuchstaben unterschieden.

Pfad-Etikett wieder ohne "(Navidrome)": der Wert kommt ohnehin aus der
Subsonic-API, die Erklärung gehört in den Code und nicht ins Fenster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:34:17 +02:00

1738 lines
86 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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-Backend&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-Backend&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-Backend&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-Backend&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 ───────────────────────────────────────────────────────────
// Liest die vorhandenen Tags einer Datei. Wird gebraucht, um einen ueber Namensaehnlichkeit
// gefundenen Kandidaten zu PRUEFEN, bevor er ueberschrieben wird.
async function _readTags(file) {
return new Promise((resolve) => {
const proc = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_entries', 'format_tags', file]);
let buf = '';
proc.stdout.on('data', d => buf += d);
proc.on('error', () => resolve({}));
proc.on('close', () => { try { resolve(JSON.parse(buf)?.format?.tags || {}); } catch { resolve({}); } });
});
}
const _tagNorm = (v) => (v || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
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-Backend&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) {
return res.status(404).json({ error: `File not found: ${filePath} (music root: ${CONFIG.musicPath})` });
}
// Der Treffer stammt aus einer Aehnlichkeitssuche ueber Dateiname bzw. Kuenstler+Titel. Liegt
// derselbe Song mehrfach auf der Platte — etwa ein Fehlgriff des Downloaders neben der richtigen
// Datei —, kann das die FALSCHE Kopie sein. Die wurde dann getaggt, waehrend der bearbeitete Song
// unveraendert blieb: die Aenderung "sprang zurueck". Deshalb wird der Kandidat gegen die Tags
// geprueft, die Navidrome fuer diesen Song meldet, und im Zweifel abgebrochen statt geraten.
const kandidatTags = await _readTags(found);
const istTitel = _tagNorm(kandidatTags.title || kandidatTags.TITLE);
const istArtist = _tagNorm(kandidatTags.artist || kandidatTags.ARTIST);
const sollTitel = _tagNorm(ndSong?.title);
const sollArtist = _tagNorm(ndSong?.artist);
const titelPasst = !sollTitel || !istTitel || istTitel === sollTitel;
const artistPasst = !sollArtist || !istArtist ||
istArtist.includes(sollArtist) || sollArtist.includes(istArtist);
if (!titelPasst || !artistPasst) {
return res.status(409).json({
error: `Datei nicht eindeutig zuzuordnen. Navidrome meldet "${ndSong?.artist || '?'} – ` +
`${ndSong?.title || '?'}", die gefundene Datei enthaelt aber "` +
`${kandidatTags.artist || '?'} – ${kandidatTags.title || '?'}" (${found}). ` +
`Vermutlich liegt derselbe Song mehrfach in der Bibliothek — bitte die Dubletten ` +
`aufraeumen, sonst wuerde die falsche Datei bearbeitet.`,
});
}
filePath = found;
}
// 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;
// Umbenennen und Verschieben werden uebersprungen, wenn am Ziel schon eine Datei liegt. Das war
// bisher stumm — der Song blieb dann unter dem alten Namen bzw. im alten Ordner liegen, ohne dass
// es jemand erfuhr. Solche Faelle kommen jetzt als Warnung mit in die Antwort.
const warnungen = [];
// 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)) {
warnungen.push(`Datei nicht umbenannt: "${path.basename(newPath)}" existiert bereits im ` +
`selben Ordner. Die Tags wurden geschrieben, der Dateiname bleibt alt.`);
} else if (newPath !== currentFile) {
try { fs.renameSync(currentFile, newPath); currentFile = newPath; }
catch (e) {
console.error('[edit-metadata] file rename:', e.message);
warnungen.push(`Umbenennen fehlgeschlagen: ${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)) {
warnungen.push(`Nicht verschoben: "${destFile}" existiert bereits. Die Datei bleibt in ` +
`"${oldAlbumDir}" — vermutlich eine Dublette.`);
} else {
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-Backend&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);
}
}
// Den tatsaechlich bearbeiteten Pfad mitgeben: wer eine Aenderung "zurueckspringen" sieht, kann
// damit sofort erkennen, ob die richtige Datei erwischt wurde.
res.json({ ok: true, file: currentFile, warnings: warnungen });
} 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-Backend&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-Backend&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('');
});