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
+339
-6
No files matched your search
+138
-1
@@ -9,10 +9,11 @@
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { execSync } = require('child_process');
|
||||
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
|
||||
|
||||
+43
-1
@@ -868,11 +868,53 @@
|
||||
<div class="inner">
|
||||
<div class="info-header">
|
||||
<div class="info-cover" id="info-cover"><div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:32px">🎵</div></div>
|
||||
<div><div class="info-top-title" id="info-top-title">—</div><div class="info-top-sub" id="info-top-sub">—</div></div>
|
||||
<div style="flex:1;min-width:0"><div class="info-top-title" id="info-top-title">—</div><div class="info-top-sub" id="info-top-sub">—</div></div>
|
||||
<button class="info-edit-toggle" id="info-edit-toggle" onclick="toggleInfoEdit()" title="Bearbeiten">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- VIEW MODE -->
|
||||
<div id="info-view-mode">
|
||||
<div class="info-rows" id="info-rows"></div>
|
||||
<button class="info-close" onclick="closeInfoModal()">Schließen</button>
|
||||
</div>
|
||||
|
||||
<!-- EDIT MODE -->
|
||||
<div id="info-edit-mode" style="display:none">
|
||||
<div class="info-edit-art-row">
|
||||
<div class="info-edit-art-wrap">
|
||||
<div class="info-cover info-edit-art-preview" id="info-edit-art-preview"></div>
|
||||
<button class="info-edit-art-btn" onclick="document.getElementById('info-art-file').click()" title="Bild hochladen">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
Upload
|
||||
</button>
|
||||
<input type="file" id="info-art-file" accept="image/*" style="display:none" onchange="onInfoArtFile(this)">
|
||||
</div>
|
||||
<button class="btn-outline blue" style="font-size:11px;padding:6px 10px;height:fit-content" onclick="autofillMusicBrainz()">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:4px"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
MusicBrainz
|
||||
</button>
|
||||
</div>
|
||||
<div class="info-edit-fields">
|
||||
<label class="info-edit-label">Titel<input class="info-edit-input" id="ie-title" type="text" placeholder="Titel"></label>
|
||||
<label class="info-edit-label">Künstler<input class="info-edit-input" id="ie-artist" type="text" placeholder="Künstler (mit ; trennen)"></label>
|
||||
<label class="info-edit-label">Album<input class="info-edit-input" id="ie-album" type="text" placeholder="Album"></label>
|
||||
<label class="info-edit-label">Genre<input class="info-edit-input" id="ie-genre" type="text" placeholder="Genre"></label>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||
<label class="info-edit-label">Jahr<input class="info-edit-input" id="ie-year" type="number" placeholder="2024" min="1900" max="2099"></label>
|
||||
<label class="info-edit-label">Track #<input class="info-edit-input" id="ie-track" type="number" placeholder="1" min="1"></label>
|
||||
<label class="info-edit-label">Disc #<input class="info-edit-input" id="ie-disc" type="number" placeholder="1" min="1"></label>
|
||||
<label class="info-edit-label">Tracks ges.<input class="info-edit-input" id="ie-totaltracks" type="number" placeholder="12" min="1"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="info-edit-status" style="font-size:12px;color:var(--text3);padding:0 20px 8px;min-height:18px"></div>
|
||||
<div style="display:flex;gap:8px;padding:0 20px 20px">
|
||||
<button class="info-close" style="flex:1;margin:0" onclick="toggleInfoEdit()">Abbrechen</button>
|
||||
<button class="info-close" style="flex:1;margin:0;background:var(--accent);color:#fff" onclick="saveMetadata()">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
@@ -32,6 +32,9 @@ export const S = {
|
||||
globalSearchTimer: null,
|
||||
_mobileSearchTimer: null,
|
||||
|
||||
// Song info / edit
|
||||
infoSong: null,
|
||||
|
||||
// Download
|
||||
downloadTarget: null,
|
||||
dlType: 'track',
|
||||
|
||||
@@ -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
|
||||
? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">`
|
||||
: '<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:28px">🎵</div>';
|
||||
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 =
|
||||
`<img src="${dataUrl}" style="width:100%;height:100%;object-fit:cover">`;
|
||||
};
|
||||
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 =
|
||||
`<img src="${d.coverUrl}" style="width:100%;height:100%;object-fit:cover" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\' style=\\'width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:28px\\'>🎵</div>'">`;
|
||||
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; }
|
||||
|
||||
+12
-1
@@ -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) ── */
|
||||
|
||||
Reference in new issue
Block a user