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
+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; }
|
||||
|
||||
Reference in new issue
Block a user