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:
1 parent
1a44f8bd73
commit
5559857c15
6 files changed
+345
-12
No files matched your search
+142
-5
@@ -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
|
||||
|
||||
Reference in new issue
Block a user