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) ── */