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 ? `` : '
🎵
'; 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]) => `
${l}${esc(String(v))}
` ).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 ? `` : '
🎵
'; 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 = _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 = ``; 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 = ``; 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('
⚠ ') + '
Datei: ' + esc(d.file || '?') + ''; // 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) => `
${esc(titel)}` + `
${esc(f.pfad || '?')}
` + `
${esc(f.artist || '?')} — ${esc(f.titel || '?')} · ` + `${_sek(f.dauer)} · ${_mb(f.groesse)}
`; box.innerHTML = zeile('Bleibt liegen (richtiger Ordner)', dub.behalten) + zeile('Überzählig (gerade bearbeitet)', dub.ueberzaehlig) + (dub.gleich ? '
Gleiche Länge und gleicher Titel — zwei Kopien desselben Songs.
' : '
Länge oder Titel weichen ab. Das Backend lehnt das Löschen ab, ' + 'solange das so ist — bitte erst selbst nachsehen.
'); 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', '
✅ Gelöscht. Navidrome scannt — danach steht der Song nur noch einmal da.
'); toast('✅ Dublette gelöscht'); } catch (e) { btn.disabled = false; btn.textContent = '🗑 Überzählige Kopie löschen'; toast('❌ ' + e.message); status.insertAdjacentHTML('beforeend', '
' + esc(e.message) + '
'); } } 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; } }