From 5559857c1555ee8d40452b994bef0931ff18637c Mon Sep 17 00:00:00 2001 From: Honey Date: Sat, 23 May 2026 19:22:11 +0200 Subject: [PATCH] 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 --- backend/novasonic-server.js | 147 ++++++++++++++++++++++++++++++++++-- index.html | 48 +++++++++++- src/main.js | 3 +- src/state.js | 3 + src/ui.js | 143 ++++++++++++++++++++++++++++++++++- styles.css | 13 +++- 6 files changed, 345 insertions(+), 12 deletions(-) diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index f2d90c9..99bc12e 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -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 diff --git a/index.html b/index.html index 792bfcd..449736f 100644 --- a/index.html +++ b/index.html @@ -868,10 +868,52 @@
🎵
-
—
—
+
—
—
+ +
+ + +
+
+ +
+ + + -
-
diff --git a/src/main.js b/src/main.js index 38c47bc..04a8430 100644 --- a/src/main.js +++ b/src/main.js @@ -42,6 +42,7 @@ import { toggleMobileSearch, closeMobileSearch, switchSetupTab, toggleNotifPanel, deleteNotif, clearNotifs, initNotifBell, _notifNavigate, _pickTheme, + toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, saveMetadata, } from './ui.js'; // ─── CONNECT ─── @@ -308,7 +309,7 @@ Object.assign(window, { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll, executeSpotifyDownload, openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt, - showSongInfo, closeInfoModal, + showSongInfo, closeInfoModal, toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, saveMetadata, openDlModal, closeDlModal, selectDlType, executeDownload, openEditPlModal, closeEditPlModal, saveEditPlaylist, editPlUploadCover, editPlCoverSelected, editPlRemoveCover, deletePlaylist, _confirmOk, _confirmCancel, diff --git a/src/state.js b/src/state.js index d8600e0..13056ed 100644 --- a/src/state.js +++ b/src/state.js @@ -32,6 +32,9 @@ export const S = { globalSearchTimer: null, _mobileSearchTimer: null, + // Song info / edit + infoSong: null, + // Download downloadTarget: null, dlType: 'track', diff --git a/src/ui.js b/src/ui.js index 3940e08..5eab932 100644 --- a/src/ui.js +++ b/src/ui.js @@ -263,20 +263,159 @@ function _renderInfoModal(s) { } export async function showSongInfo(s) { + S.infoSong = s; + S._infoArtData = null; document.getElementById('info-modal').classList.add('open'); + _leaveEditMode(); _renderInfoModal(s); try { const r = await api('getSong', `&id=${s.id}`); const full = r.song; - if (full) _renderInfoModal(full); + if (full) { S.infoSong = full; _renderInfoModal(full); } } catch {} } -export function closeInfoModal() { document.getElementById('info-modal').classList.remove('open'); } +export function closeInfoModal() { + document.getElementById('info-modal').classList.remove('open'); + _leaveEditMode(); + S.infoSong = null; + S._infoArtData = null; +} document.addEventListener('click', e => { const m = document.getElementById('info-modal'); if(m.classList.contains('open') && e.target===m) closeInfoModal(); }); +function _leaveEditMode() { + document.getElementById('info-view-mode').style.display = ''; + document.getElementById('info-edit-mode').style.display = 'none'; + document.getElementById('info-edit-toggle').classList.remove('active'); + document.getElementById('info-edit-status').textContent = ''; +} + +export function toggleInfoEdit() { + const editEl = document.getElementById('info-edit-mode'); + const viewEl = document.getElementById('info-view-mode'); + const btn = document.getElementById('info-edit-toggle'); + const isEdit = editEl.style.display !== 'none'; + if (isEdit) { _leaveEditMode(); return; } + + const s = S.infoSong; + if (!s) return; + editEl.style.display = ''; + viewEl.style.display = 'none'; + btn.classList.add('active'); + + // Populate fields from current song + document.getElementById('ie-title').value = s.title || ''; + document.getElementById('ie-artist').value = s.artist || ''; + document.getElementById('ie-album').value = s.album || ''; + document.getElementById('ie-genre').value = s.genre || ''; + document.getElementById('ie-year').value = s.year || ''; + document.getElementById('ie-track').value = s.track || ''; + document.getElementById('ie-disc').value = s.discNumber || ''; + document.getElementById('ie-totaltracks').value = s.albumSongCount || ''; + + // Art preview — use existing cover + const cv = coverUrl(s.coverArt, 200); + const prev = document.getElementById('info-edit-art-preview'); + prev.innerHTML = cv + ? `` + : '
🎵
'; + S._infoArtData = null; +} + +export function onInfoArtFile(input) { + const file = input.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = e => { + const dataUrl = e.target.result; + const base64 = dataUrl.split(',')[1]; + S._infoArtData = { base64, mime: file.type }; + document.getElementById('info-edit-art-preview').innerHTML = + ``; + }; + reader.readAsDataURL(file); +} + +export async function autofillMusicBrainz() { + if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; } + const s = S.infoSong; + if (!s) return; + const artist = document.getElementById('ie-artist').value || s.artist || ''; + const title = document.getElementById('ie-title').value || s.title || ''; + const status = document.getElementById('info-edit-status'); + status.textContent = '🔍 MusicBrainz…'; + try { + const r = await fetch(`${S.cfg.backendUrl}/api/musicbrainz/lookup?artist=${encodeURIComponent(artist)}&title=${encodeURIComponent(title)}`); + const d = await r.json(); + if (!d.found) { status.textContent = '❌ Kein Treffer auf MusicBrainz'; return; } + if (d.title) document.getElementById('ie-title').value = d.title; + if (d.artist) document.getElementById('ie-artist').value = d.artist; + if (d.album) document.getElementById('ie-album').value = d.album; + if (d.genre) document.getElementById('ie-genre').value = d.genre; + if (d.year) document.getElementById('ie-year').value = d.year; + if (d.trackNum) document.getElementById('ie-track').value = d.trackNum; + if (d.totalTracks) document.getElementById('ie-totaltracks').value = d.totalTracks; + // Offer cover art from Cover Art Archive + if (d.coverUrl) { + status.textContent = '✅ Metadaten gefunden — Cover wird geladen…'; + // Store URL for save; show preview (CAA redirects, use as img src) + S._infoArtData = { url: d.coverUrl }; + document.getElementById('info-edit-art-preview').innerHTML = + ``; + status.textContent = '✅ Metadaten + Cover von MusicBrainz'; + } else { + status.textContent = '✅ Metadaten von MusicBrainz (kein Cover)'; + } + } catch (e) { + status.textContent = '❌ Fehler: ' + e.message; + } +} + +export async function saveMetadata() { + if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; } + const s = S.infoSong; + if (!s?.id) return; + const status = document.getElementById('info-edit-status'); + status.textContent = '💾 Speichern…'; + + const body = { + ndUrl: S.cfg.url, + user: S.cfg.user, + pass: S.cfg.pass, + songId: s.id, + title: document.getElementById('ie-title').value || undefined, + artist: document.getElementById('ie-artist').value || undefined, + album: document.getElementById('ie-album').value || undefined, + genre: document.getElementById('ie-genre').value || undefined, + year: parseInt(document.getElementById('ie-year').value) || undefined, + trackNum: parseInt(document.getElementById('ie-track').value) || undefined, + totalTracks: parseInt(document.getElementById('ie-totaltracks').value) || undefined, + discNum: parseInt(document.getElementById('ie-disc').value) || undefined, + }; + if (S._infoArtData?.base64) { + body.artworkBase64 = S._infoArtData.base64; + body.artworkMime = S._infoArtData.mime || 'image/jpeg'; + } else if (S._infoArtData?.url) { + body.artworkUrl = S._infoArtData.url; + } + + try { + const r = await fetch(`${S.cfg.backendUrl}/api/edit-metadata`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const d = await r.json(); + if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`); + status.textContent = '✅ Gespeichert! Navidrome scannt…'; + toast('✅ Metadaten gespeichert'); + setTimeout(() => { _leaveEditMode(); closeInfoModal(); }, 1500); + } catch (e) { + status.textContent = '❌ Fehler: ' + e.message; + } +} + // ─── DOWNLOAD MODAL ─── export function openDlModal(track) { if(!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert. Bitte in Einstellungen eintragen.'); return; } diff --git a/styles.css b/styles.css index 52a572e..56734a4 100644 --- a/styles.css +++ b/styles.css @@ -465,7 +465,7 @@ button:focus{outline:none} /* ── SONG INFO MODAL ── */ .info-modal{position:fixed;inset:0;background:rgba(0,0,0,.65);backdrop-filter:blur(4px);z-index:400;display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .2s} .info-modal.open{opacity:1;pointer-events:all} -.info-modal .inner{width:min(420px,calc(100vw - 32px));background:var(--surface);border:1px solid var(--border2);border-radius:16px;padding:0;overflow:hidden;box-shadow:0 32px 64px rgba(0,0,0,.7)} +.info-modal .inner{width:min(420px,calc(100vw - 32px));max-height:calc(100vh - 48px);background:var(--surface);border:1px solid var(--border2);border-radius:16px;padding:0;overflow-y:auto;box-shadow:0 32px 64px rgba(0,0,0,.7)} .info-header{display:flex;gap:16px;padding:20px;background:var(--bg2)} .info-cover{width:80px;height:80px;border-radius:8px;overflow:hidden;background:var(--surface2);flex-shrink:0} .info-cover img{width:100%;height:100%;object-fit:cover} @@ -478,6 +478,17 @@ button:focus{outline:none} .info-val{color:var(--text);text-align:right;word-break:break-all} .info-close{display:block;width:calc(100% - 40px);margin:0 20px 20px;padding:11px;border-radius:9px;background:var(--surface2);border:none;font-family:var(--font);font-size:14px;font-weight:600;color:var(--text);cursor:pointer} .info-close:hover{background:var(--surface)} +.info-edit-toggle{background:none;border:none;color:var(--text2);cursor:pointer;padding:4px;border-radius:6px;display:flex;align-items:center;flex-shrink:0;align-self:flex-start;margin-top:2px} +.info-edit-toggle:hover,.info-edit-toggle.active{color:var(--accent)} +.info-edit-art-row{display:flex;align-items:center;gap:12px;padding:0 20px 12px} +.info-edit-art-wrap{display:flex;flex-direction:column;align-items:center;gap:6px} +.info-edit-art-preview{width:72px;height:72px;border-radius:8px;overflow:hidden;background:var(--surface2);flex-shrink:0} +.info-edit-art-btn{font-size:11px;color:var(--text2);background:var(--surface2);border:none;border-radius:6px;padding:4px 8px;cursor:pointer;display:flex;align-items:center;gap:4px;font-family:var(--font)} +.info-edit-art-btn:hover{color:var(--text)} +.info-edit-fields{display:flex;flex-direction:column;gap:8px;padding:0 20px 4px} +.info-edit-label{display:flex;flex-direction:column;font-size:11px;color:var(--text3);gap:3px;font-weight:500} +.info-edit-input{background:var(--surface2);border:1px solid var(--border2);border-radius:7px;color:var(--text);font-family:var(--font);font-size:13px;padding:7px 10px;outline:none;width:100%;box-sizing:border-box} +.info-edit-input:focus{border-color:var(--accent)} /* ── BOTTOM NAV (PWA Mobile) ── */