400 lines
20 KiB
JavaScript
400 lines
20 KiB
JavaScript
import { S } from '../state.js';
|
|
import { esc, formatDur, toast } from '../utils.js';
|
|
import { api, coverUrl, fetchRomajiIndex, romajiLookup, getRomajiOverrides, invalidateRomajiIndex, invalidateApiCache } from '../api.js';
|
|
// Circular — safe (only used inside function bodies)
|
|
import { loadPlaylists, loadPlaylistDetail, refreshCurrentView } from '../views/views.js';
|
|
import { showConfirm } from './modals.js';
|
|
|
|
// ─── SONG INFO MODAL ───
|
|
function _fmtArtists(str, sep) {
|
|
if (!str) return '';
|
|
let s = str.trim();
|
|
// Apple Music / MusicBrainz render multi-artist lists as "A, B, C & D" — the LAST artist is
|
|
// joined with " & " instead of the list separator, which would otherwise stay glued together as
|
|
// one artist. Only rewrite the " & " when the string is already a list (contains ; , • ·), so
|
|
// genuine band names like "Simon & Garfunkel" or "Earth, Wind & Fire" without a leading list are
|
|
// left intact. (Manual editor — the user can still fix the rare false positive before saving.)
|
|
if (/[;,•·]/.test(s)) s = s.replace(/\s+&\s+/g, '; ');
|
|
return s.split(/\s*[•·;,]\s*/).map(x => x.trim()).filter(Boolean).join(sep);
|
|
}
|
|
|
|
function _renderInfoModal(s) {
|
|
const cv = coverUrl(s.coverArt, 200);
|
|
document.getElementById('info-cover').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:32px">🎵</div>';
|
|
document.getElementById('info-top-title').textContent = s.title || '—';
|
|
document.getElementById('info-top-sub').textContent = (s.artist||'—') + (s.album ? ' · '+s.album : '');
|
|
const fields = [
|
|
['Titel', s.title],
|
|
['Künstler', s.artist],
|
|
['Album-Künstler', s.albumArtist],
|
|
['Album', s.album],
|
|
['Jahr', s.year],
|
|
['Track #', s.track],
|
|
['Disc #', s.discNumber],
|
|
['Genre', s.genre],
|
|
['Dauer', formatDur(s.duration)],
|
|
['Bitrate', s.bitRate ? s.bitRate+' kbps' : null],
|
|
['Format', s.suffix?.toUpperCase()],
|
|
['Dateigröße', s.size ? Math.round(s.size/1024/1024*10)/10+' MB' : null],
|
|
// Kommt so aus der Subsonic-API. Ohne "Report Real Path" am Player ist das NICHT der echte
|
|
// Dateiname, sondern von Navidrome aus den Tags gebaut (fakePath(): AlbumArtist/Album/
|
|
// DD-TT - Titel.ext) — siehe CHANGELOG 2.4, Known Bugs.
|
|
['Pfad', s.path],
|
|
['Plays', s.playCount],
|
|
].filter(([,v]) => v!=null && v!=='' && v!==undefined);
|
|
document.getElementById('info-rows').innerHTML = fields.map(([l,v]) =>
|
|
`<div class="info-row"><span class="info-label">${l}</span><span class="info-val">${esc(String(v))}</span></div>`
|
|
).join('');
|
|
}
|
|
|
|
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) {
|
|
if (!full.albumArtist && full.albumId) {
|
|
try {
|
|
const ar = await api('getAlbum', `&id=${full.albumId}`);
|
|
if (ar.album?.artist) full.albumArtist = ar.album.artist;
|
|
} catch {}
|
|
}
|
|
S.infoSong = full;
|
|
_renderInfoModal(full);
|
|
if (document.getElementById('info-edit-mode').style.display !== 'none') {
|
|
document.getElementById('ie-title').value = full.title || '';
|
|
document.getElementById('ie-artist').value = _fmtArtists(full.artist, '; ') || '';
|
|
document.getElementById('ie-albumartist').value = _fmtArtists(full.albumArtist, ', ') || '';
|
|
document.getElementById('ie-album').value = full.album || '';
|
|
document.getElementById('ie-genre').value = full.genre || '';
|
|
document.getElementById('ie-year').value = full.year || '';
|
|
document.getElementById('ie-track').value = full.track || '';
|
|
document.getElementById('ie-disc').value = full.discNumber || '';
|
|
document.getElementById('ie-totaltracks').value = full.albumSongCount || '';
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
export function closeInfoModal() {
|
|
document.getElementById('info-modal').classList.remove('open');
|
|
_leaveEditMode();
|
|
S.infoSong = null;
|
|
S._infoArtData = null;
|
|
}
|
|
|
|
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');
|
|
|
|
document.getElementById('ie-title').value = s.title || '';
|
|
document.getElementById('ie-artist').value = _fmtArtists(s.artist, '; ') || '';
|
|
document.getElementById('ie-albumartist').value = _fmtArtists(s.albumArtist, ', ') || '';
|
|
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 || '';
|
|
|
|
document.getElementById('ie-titlesort').value = '';
|
|
document.getElementById('ie-artistsort').value = '';
|
|
document.getElementById('ie-albumartistsort').value = '';
|
|
document.getElementById('ie-albumsort').value = '';
|
|
|
|
const _sId = s.id;
|
|
fetchRomajiIndex().catch(() => null).then(() => {
|
|
const rm = romajiLookup(_sId);
|
|
if (rm) {
|
|
const _jp = /[ぁ-んァ-ン一-龯]/;
|
|
if (rm.titleRomaji && !_jp.test(rm.titleRomaji)) document.getElementById('ie-titlesort').value = rm.titleRomaji;
|
|
if (rm.artistRomaji && !_jp.test(rm.artistRomaji)) {
|
|
document.getElementById('ie-artistsort').value = rm.artistRomaji;
|
|
document.getElementById('ie-albumartistsort').value = rm.artistRomaji;
|
|
}
|
|
if (rm.albumRomaji && !_jp.test(rm.albumRomaji)) document.getElementById('ie-albumsort').value = rm.albumRomaji;
|
|
}
|
|
if (!S.cfg.backendUrl || !_sId) return null;
|
|
const p = new URLSearchParams({ ndUrl: S.cfg.url, user: S.cfg.user, pass: S.cfg.pass, songId: _sId });
|
|
return fetch(`${S.cfg.backendUrl}/api/song-sorttags?${p}`).then(r => r.ok ? r.json() : null).catch(() => null);
|
|
}).then(t => {
|
|
if (t) {
|
|
const _jp = /[ぁ-んァ-ン一-龯]/;
|
|
if (t.titleSort && !_jp.test(t.titleSort)) document.getElementById('ie-titlesort').value = t.titleSort;
|
|
if (t.artistSort && !_jp.test(t.artistSort)) document.getElementById('ie-artistsort').value = t.artistSort;
|
|
if (t.albumArtistSort && !_jp.test(t.albumArtistSort)) document.getElementById('ie-albumartistsort').value = t.albumArtistSort;
|
|
if (t.albumSort && !_jp.test(t.albumSort)) document.getElementById('ie-albumsort').value = t.albumSort;
|
|
}
|
|
const ov = getRomajiOverrides();
|
|
if (s.artist && ov[s.artist]) { document.getElementById('ie-artistsort').value = ov[s.artist]; document.getElementById('ie-albumartistsort').value = ov[s.artist]; }
|
|
if (s.albumArtist && ov[s.albumArtist]) document.getElementById('ie-albumartistsort').value = ov[s.albumArtist];
|
|
if (s.title && ov[s.title]) document.getElementById('ie-titlesort').value = ov[s.title];
|
|
if (s.album && ov[s.album]) document.getElementById('ie-albumsort').value = ov[s.album];
|
|
}).catch(() => {});
|
|
|
|
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 = _fmtArtists(d.artist, '; ');
|
|
if (d.albumArtist) document.getElementById('ie-albumartist').value = _fmtArtists(d.albumArtist, ', ');
|
|
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;
|
|
if (d.discNum) document.getElementById('ie-disc').value = d.discNum;
|
|
if (d.coverUrl) {
|
|
status.textContent = '✅ Metadaten gefunden — Cover wird geladen…';
|
|
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 autofillDeezer() {
|
|
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 = '🔍 Deezer…';
|
|
try {
|
|
const r = await fetch(`${S.cfg.backendUrl}/api/meta/lookup?artist=${encodeURIComponent(artist)}&title=${encodeURIComponent(title)}`);
|
|
const d = await r.json();
|
|
if (!d.found) { status.textContent = '❌ Kein Treffer auf Deezer'; return; }
|
|
if (d.title) document.getElementById('ie-title').value = d.title;
|
|
if (d.artist) document.getElementById('ie-artist').value = _fmtArtists(d.artist, '; ');
|
|
if (d.albumArtist) document.getElementById('ie-albumartist').value = _fmtArtists(d.albumArtist, ', ');
|
|
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;
|
|
if (d.discNum) document.getElementById('ie-disc').value = d.discNum;
|
|
if (d.coverUrl) {
|
|
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 Deezer';
|
|
} else {
|
|
status.textContent = '✅ Metadaten von Deezer (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,
|
|
albumArtist: document.getElementById('ie-albumartist').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,
|
|
titleSort: document.getElementById('ie-titlesort').value || undefined,
|
|
artistSort: document.getElementById('ie-artistsort').value || undefined,
|
|
albumArtistSort:document.getElementById('ie-albumartistsort').value || undefined,
|
|
albumSort: document.getElementById('ie-albumsort').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}`);
|
|
invalidateRomajiIndex();
|
|
invalidateApiCache('getArtists');
|
|
// Warnungen des Backends (Dublette am Zielnamen, nicht verschoben, …) sichtbar machen und das
|
|
// Fenster offen lassen — sonst sieht es nach "gespeichert" aus, obwohl die Datei liegen blieb.
|
|
if (d.warnings?.length) {
|
|
status.innerHTML = '⚠ Gespeichert, aber: ' + d.warnings.map(esc).join('<br>⚠ ') +
|
|
'<br><span style="color:var(--text3)">Datei: ' + esc(d.file || '?') + '</span>';
|
|
// Liegt derselbe Song doppelt, kann das Aufraeumen hier passieren statt auf der Konsole.
|
|
if (d.duplicate) _zeigeDublette(status, d.duplicate);
|
|
toast('⚠ Gespeichert — mit Hinweisen, siehe Fenster');
|
|
return;
|
|
}
|
|
status.textContent = '✅ Gespeichert! Navidrome scannt…';
|
|
toast('✅ Metadaten gespeichert');
|
|
setTimeout(() => { _leaveEditMode(); closeInfoModal(); }, 1500);
|
|
} catch (e) {
|
|
status.textContent = '❌ Fehler: ' + e.message;
|
|
}
|
|
}
|
|
|
|
// ─── DUBLETTE AUFLOESEN ───
|
|
// Das Verschieben wurde abgelehnt, weil am Ziel schon eine Datei liegt — derselbe Song liegt also
|
|
// zweimal auf der Platte. Statt den Nutzer zum Dateimanager zu schicken, werden beide Kopien
|
|
// gegenuebergestellt und die ueberzaehlige zum Loeschen angeboten.
|
|
const _mb = (n) => (n == null ? '?' : (n / 1048576).toFixed(1) + ' MB');
|
|
const _sek = (n) => (n == null ? '?' : formatDur(n));
|
|
|
|
function _zeigeDublette(status, dub) {
|
|
const box = document.createElement('div');
|
|
box.className = 'dublette-box';
|
|
const zeile = (titel, f) =>
|
|
`<div class="dublette-datei"><b>${esc(titel)}</b>` +
|
|
`<div class="dublette-pfad">${esc(f.pfad || '?')}</div>` +
|
|
`<div class="dublette-meta">${esc(f.artist || '?')} — ${esc(f.titel || '?')} · ` +
|
|
`${_sek(f.dauer)} · ${_mb(f.groesse)}</div></div>`;
|
|
box.innerHTML =
|
|
zeile('Bleibt liegen (richtiger Ordner)', dub.behalten) +
|
|
zeile('Überzählig (gerade bearbeitet)', dub.ueberzaehlig) +
|
|
(dub.gleich
|
|
? '<div class="dublette-urteil ok">Gleiche Länge und gleicher Titel — zwei Kopien desselben Songs.</div>'
|
|
: '<div class="dublette-urteil warn">Länge oder Titel weichen ab. Das Backend lehnt das Löschen ab, ' +
|
|
'solange das so ist — bitte erst selbst nachsehen.</div>');
|
|
const btn = document.createElement('button');
|
|
btn.className = 'btn-danger';
|
|
btn.textContent = '🗑 Überzählige Kopie löschen';
|
|
btn.onclick = () => _loescheDublette(btn, status, dub);
|
|
box.appendChild(btn);
|
|
status.appendChild(box);
|
|
}
|
|
|
|
async function _loescheDublette(btn, status, dub) {
|
|
const ok = await showConfirm(
|
|
'Dublette löschen',
|
|
`"${dub.ueberzaehlig.pfad}" wird unwiderruflich von der Festplatte gelöscht. ` +
|
|
`Die Kopie im richtigen Ordner bleibt bestehen.`,
|
|
'Löschen'
|
|
);
|
|
if (!ok) return;
|
|
btn.disabled = true;
|
|
btn.textContent = '🗑 Löschen…';
|
|
try {
|
|
const r = await fetch(`${S.cfg.backendUrl}/api/resolve-duplicate`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
ndUrl: S.cfg.url, user: S.cfg.user, pass: S.cfg.pass,
|
|
behalten: dub.behalten.pfad, ueberzaehlig: dub.ueberzaehlig.pfad,
|
|
}),
|
|
});
|
|
const d = await r.json();
|
|
if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`);
|
|
invalidateApiCache('getArtists');
|
|
invalidateRomajiIndex();
|
|
btn.remove();
|
|
status.insertAdjacentHTML('beforeend',
|
|
'<div class="dublette-urteil ok">✅ Gelöscht. Navidrome scannt — danach steht der Song nur noch einmal da.</div>');
|
|
toast('✅ Dublette gelöscht');
|
|
} catch (e) {
|
|
btn.disabled = false;
|
|
btn.textContent = '🗑 Überzählige Kopie löschen';
|
|
toast('❌ ' + e.message);
|
|
status.insertAdjacentHTML('beforeend',
|
|
'<div class="dublette-urteil warn">' + esc(e.message) + '</div>');
|
|
}
|
|
}
|
|
|
|
export async function deleteSong() {
|
|
if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; }
|
|
const s = S.infoSong;
|
|
if (!s?.id) return;
|
|
const ok = await showConfirm(
|
|
'Song löschen',
|
|
`"${s.title}" wird unwiderruflich von der Festplatte gelöscht. Leere Album- und Künstlerordner werden ebenfalls entfernt.`,
|
|
'Löschen'
|
|
);
|
|
if (!ok) return;
|
|
const status = document.getElementById('info-edit-status');
|
|
status.textContent = '🗑 Löschen…';
|
|
try {
|
|
const r = await fetch(`${S.cfg.backendUrl}/api/delete-song`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ndUrl: S.cfg.url, user: S.cfg.user, pass: S.cfg.pass, songId: s.id }),
|
|
});
|
|
const d = await r.json();
|
|
if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`);
|
|
toast(`🗑 "${s.title}" gelöscht`);
|
|
closeInfoModal();
|
|
// Backend delete + Navidrome rescan takes a moment; then refresh whatever view is open (album,
|
|
// artist, playlist, starred, …) so the removed song disappears without a manual reload.
|
|
setTimeout(() => refreshCurrentView(), 3000);
|
|
} catch (e) {
|
|
status.textContent = '❌ Fehler: ' + e.message;
|
|
}
|
|
}
|