feat: metadata editor with MusicBrainz autofill + cover art upload

- Info modal gets an edit pencil button that switches to edit form
- Editable fields: title, artist, album, genre, year, track#, disc#, total tracks
- Cover art: upload your own image or auto-fetch from Cover Art Archive via MusicBrainz
- MusicBrainz button queries /api/musicbrainz/lookup and autofills all fields
- Save POSTs to /api/edit-metadata which rewrites tags via ffmpeg and triggers Navidrome rescan
- Backend: GET /api/musicbrainz/lookup + POST /api/edit-metadata endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-23 19:22:11 +02:00
1 parent 1a44f8bd73
commit 5559857c15
6 files changed
+345 -12

No files matched your search

+142 -5
View File
@@ -8,11 +8,12 @@
* - ffmpeg installed
*/
const express = require('express');
const { execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const https = require('https');
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');
@@ -388,6 +389,142 @@ app.get('/api/jobs', (req, res) => {
res.json(Array.from(jobs.values()).sort((a, b) => b.createdAt - a.createdAt).slice(0, 50));
});
// ─── 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 {
const query = `recording:"${title.replace(/"/g, '')}" AND artist:"${artist.replace(/"/g, '')}"`;
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 });
}
});
// ─── METADATA EDIT ───────────────────────────────────────────────────────────
app.post('/api/edit-metadata', async (req, res) => {
const { ndUrl, user, pass, songId, title, artist, album, year, trackNum, totalTracks,
discNum, discTotal, genre, artworkBase64, artworkMime, artworkUrl: newArtworkUrl } = req.body;
if (!ndUrl || !user || !pass || !songId) return res.status(400).json({ error: 'ndUrl, user, pass, songId required' });
// Resolve 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 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();
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}` });
}
// If not absolute, try relative to music root
if (!path.isAbsolute(filePath)) filePath = path.join(CONFIG.musicPath, filePath);
if (!fs.existsSync(filePath)) 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');
if (artFile) ffArgs.push('-map', '1:v');
ffArgs.push('-c', 'copy', '-map_metadata', '-1', '-id3v2_version', '3');
if (title) ffArgs.push('-metadata', `title=${title}`);
if (artist) ffArgs.push('-metadata', `artist=${artist}`, '-metadata', `album_artist=${artist.split(';')[0].trim()}`);
if (album) ffArgs.push('-metadata', `album=${album}`);
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}`);
if (artFile) 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 {}
// 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