Files
NovaSonic/backend/novasonic-server.js
T

905 lines
44 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 crypto = require('crypto');
const CONFIG = require('./lib/config');
const { jobs, updateJob, log } = require('./lib/jobs');
const { sanitize, lookupItunesTrackMeta, fetchItunesTracks, searchItunesAlbumTracks } = require('./lib/metadata');
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;
}
// ─── 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 });
});
// 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 lookupItunesTrackMeta(searchArtist, t.trackName || '');
return { idx: t.idx, artworkUrl: meta.artworkUrl || t.existingArt || '' };
}));
results.push(...batchRes);
}
res.json({ results });
});
// ─── ITUNES METADATA LOOKUP (must be before the proxy route) ─────────────────
app.get('/api/itunes/lookup', async (req, res) => {
const { artist, title } = req.query;
if (!artist || !title) return res.status(400).json({ error: 'artist and title required' });
try {
const meta = await lookupItunesTrackMeta(artist, title);
if (!meta.album && !meta.genre && !meta.year) return res.json({ found: false });
res.json({
found: true,
album: meta.album || undefined,
year: meta.year || undefined,
trackNum: meta.trackNum || undefined,
totalTracks: meta.totalTracks || undefined,
genre: meta.genre || undefined,
coverUrl: meta.artworkUrl || undefined,
artist: meta.itunesArtistName || undefined,
});
} catch (e) {
console.error('[itunes/lookup]', e.message);
res.status(500).json({ error: e.message });
}
});
// ─── ITUNES PROXY ─────────────────────────────────────────────────────────────
// Browsers can't call itunes.apple.com directly (no CORS headers). Proxy it here.
// Note: /api/itunes/lookup is handled above — proxy only covers /search and id-based /lookup.
app.get('/api/itunes/:endpoint', (req, res) => {
const allowed = new Set(['search', 'lookup']);
if (!allowed.has(req.params.endpoint)) return res.status(404).json({ error: 'not found' });
const qs = new URLSearchParams(req.query).toString();
const target = `https://itunes.apple.com/${req.params.endpoint}${qs ? '?' + qs : ''}`;
https.get(target, { headers: { 'User-Agent': 'NovaSonic/1.0' } }, upstream => {
res.setHeader('Content-Type', 'application/json');
upstream.pipe(res);
}).on('error', e => res.status(502).json({ error: e.message }));
});
app.get('/api/health', (req, res) => {
res.json({
status: 'ok', ytdlpVersion,
musicPath: CONFIG.musicPath,
audioFormat: CONFIG.audioFmt,
downloadBackend: CONFIG.downloadBackend,
squidQuality: CONFIG.squidQuality,
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 iTunes
const e = entries[0];
const rawArtist = _cleanYtArtist(e.uploader || e.channel || e.creator || '');
const title = e.title || '';
const sourceUrl = e.webpage_url || url;
let itunes = {};
try { itunes = await lookupItunesTrackMeta(rawArtist.split(',')[0].trim(), title); } catch {}
const artist = itunes.artist || rawArtist;
const artworkUrl = itunes.artworkUrl || e.thumbnail || '';
return res.json({
type: 'track', name: title, artist, artworkUrl,
tracks: [{
trackName: title,
artistName: artist,
artworkUrl: artworkUrl,
collectionName: itunes.album || '',
releaseDate: itunes.year ? String(itunes.year) : '',
trackNumber: itunes.trackNum || undefined,
durationMs: itunes.durationMs || (e.duration ? Math.round(e.duration * 1000) : undefined),
genre: itunes.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 || undefined,
};
if (!meta.album) {
const searchArtist = artist.split(',')[0].trim();
log(jobId, '📋 Suche Album-Metadaten von iTunes…');
const extra = await lookupItunesTrackMeta(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 aus Spotify: "${meta.album}"`);
}
const finalArtist = meta.itunesArtistName || 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 {
updateJob(jobId, { status: 'done', progress: 100, message: 'Fertig!' });
}
} else if (type === 'album') {
let tracks = [];
if (collectionId) {
log(jobId, '📋 Lade Track-Liste von iTunes…');
tracks = await fetchItunesTracks(collectionId);
}
if (!tracks.length) {
log(jobId, '🔍 Suche Album auf iTunes…');
tracks = await searchItunesAlbumTracks(artist, album || title);
}
if (!tracks.length) {
throw new Error(`Keine einzelnen Tracks für "${album || title}" gefunden. Bitte über iTunes Global-Suche herunterladen.`);
}
const { done: dlDone, skipped: dlSkipped } = await downloadAlbumTracks({ jobId, artist, 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}`);
const artistData = await new Promise((resolve, reject) => {
https.get(`https://itunes.apple.com/search?term=${encodeURIComponent(artist)}&media=music&entity=album&limit=10&country=de`, res => {
let d = ''; res.on('data', c => d += c);
res.on('end', () => { try { resolve(JSON.parse(d).results || []); } catch { reject(new Error('parse')); } });
}).on('error', reject);
});
const artistAlbums = artistData.filter(a => a.artistName?.toLowerCase() === artist.toLowerCase()).slice(0, 10);
log(jobId, `📀 ${artistAlbums.length} Alben gefunden`);
let done = 0;
for (const alb of artistAlbums) {
const tracks = await fetchItunesTracks(alb.collectionId).catch(() => []);
if (tracks.length) {
await downloadAlbumTracks({ jobId, artist, 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;
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 || '';
// If the frontend already enriched this track with iTunes data (collectionName +
// trackNumber + durationMs all present), skip the re-lookup to avoid a different
// iTunes result overriding the already-correct enriched metadata.
const alreadyEnriched = !!(track.collectionName && track.trackNumber && track.durationMs);
let itunes = {};
if (alreadyEnriched) {
log(jobId, `📋 Frontend-Metadaten: "${trackArtist} – ${trackTitle}" (${track.collectionName})`);
} else {
const searchArtist = trackArtist.split(',')[0].trim();
log(jobId, `📋 iTunes: "${trackArtist} – ${trackTitle}"`);
itunes = await lookupItunesTrackMeta(searchArtist, trackTitle);
}
// Prefer iTunes-resolved name (from server lookup or frontend enrichment) for sort tags / folders.
// trackArtist stays as rawArtist for main tags and YTM search.
const resolvedArtist = itunes.itunesArtistName || track.itunesArtistName || trackArtist;
return downloadTrack({
jobId,
artist: resolvedArtist, // iTunes English name → used for sort tags + folder names
rawArtist: trackArtist, // original Spotify/Japanese name → used for main tag + YTM search
albumArtist: resolvedArtist,
title: trackTitle,
album: itunes.album || track.collectionName || track.album || '',
year: itunes.year || (track.releaseDate ? new Date(track.releaseDate).getFullYear() : undefined) || track.year,
trackNum: itunes.trackNum || track.trackNumber || undefined,
totalTracks: itunes.totalTracks || undefined,
genre: itunes.genre || track.primaryGenreName || track.genre || undefined,
artworkUrl: itunes.artworkUrl || track.artworkUrl100 || track.artworkUrl || artworkUrl || '',
explicit: track.trackExplicitness === 'explicit',
durationMs: itunes.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 {
done++;
}
updateJob(jobId, { progress: Math.round(((done + skipped) / 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` : ''}`,
});
}
} catch (err) {
console.error(`[${jobId}] Error:`, err.message);
updateJob(jobId, { status: 'error', message: err.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 coverUrl = release?.id ? `https://coverartarchive.org/release/${release.id}/front` : null;
res.json({
found: true,
title: rec.title || title,
artist: mbArtist || artist,
album: release?.title || '',
year: year ? parseInt(year, 10) : undefined,
trackNum: trackNum,
totalTracks: totalTracks,
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 found = _findFileRecursive(CONFIG.musicPath, path.basename(filePath));
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 }); }
});
// ─── 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}` });
}
// If not absolute, try relative to music root
if (!path.isAbsolute(filePath)) filePath = path.join(CONFIG.musicPath, filePath);
// Fallback: if Navidrome path stale/wrong, search for the file by name under music root
if (!fs.existsSync(filePath)) {
const fileName = path.basename(filePath);
const found = _findFileRecursive(CONFIG.musicPath, fileName);
if (found) { filePath = found; }
else return res.status(404).json({ error: `File not found: ${filePath}` });
}
// 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
if (title) {
const safeTitle = sanitize(romajiTitle || title);
const fileArtist = (romajiArtist || 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 = romajiAlbum ? sanitize(romajiAlbum) : path.basename(oldAlbumDir);
const newArtistName = romajiAlbumArtist ? sanitize(romajiAlbumArtist.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 (romajiAlbum && path.basename(oldAlbumDir) !== sanitize(romajiAlbum)) {
// Only album name changed (no artist move) — rename album folder in place
const renamedDir = path.join(oldArtistDir, sanitize(romajiAlbum));
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 {}
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 });
}
});
// ─── 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(` Download backend : ${CONFIG.downloadBackend}`);
if (CONFIG.downloadBackend !== 'ytdlp') {
console.log(` Squid quality : ${CONFIG.squidQuality}`);
}
console.log(` yt-dlp : ${ytdlpVersion}`);
console.log('');
});