Files
NovaSonic/src/ui.js
T
HoneyandClaude Sonnet 4.6 11a31f85aa fix: populate albumArtist in metadata editor via getAlbum fallback
getSong often omits albumArtist; getAlbum always returns album.artist.
Enrich the song object before showing the edit form.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 22:50:22 +02:00

930 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { S } from './state.js';
import { esc, formatDur, toast } from './utils.js';
import { api, coverUrl, ndAuth } from './api.js';
import { THEMES, applyTheme, getCurrentThemeId } from './themes.js';
// Circular imports — safe (only used inside function bodies)
import { playQueue, dlSongLocal, toggleStar, togglePlay, renderQueue, syncNpStar, updateStarBtn, npToggleStar, playCurrent, _syncNpDirect, setPlayIcon, nativeInsertAt } from './player.js';
import { navigate, loadPlaylists } from './views.js';
// ─── SIDEBAR ───
export function openSidebar() {
document.getElementById('sidebar').classList.add('mobile-open');
document.getElementById('mobile-overlay').classList.add('active');
}
export function closeSidebar() {
document.getElementById('sidebar').classList.remove('mobile-open');
document.getElementById('mobile-overlay').classList.remove('active');
}
export function switchNavMode(mode) {
const lib = document.getElementById('bnav-library');
const disc = document.getElementById('bnav-discover');
if(!lib || !disc) return;
lib.style.display = mode==='library' ? 'flex' : 'none';
disc.style.display = mode==='discover' ? 'flex' : 'none';
document.getElementById('bnav-tab-library')?.classList.toggle('active', mode==='library');
document.getElementById('bnav-tab-discover')?.classList.toggle('active', mode==='discover');
}
// ─── MOBILE SEARCH ───
export function toggleMobileSearch() {
const bar = document.getElementById('mobile-search-bar');
const btn = document.getElementById('mobile-search-btn');
const open = bar.style.display==='none' || bar.style.display==='';
bar.style.display = open ? 'block' : 'none';
btn.style.color = open ? 'var(--accent)' : 'var(--text2)';
if(open) { setTimeout(() => document.getElementById('mobile-search-input').focus(), 50); }
else { closeMobileSearch(); }
}
export function closeMobileSearch() {
const bar = document.getElementById('mobile-search-bar');
const inp = document.getElementById('mobile-search-input');
const btn = document.getElementById('mobile-search-btn');
bar.style.display = 'none';
btn.style.color = 'var(--text2)';
inp.value = '';
navigate('home');
}
// ─── SETUP ───
export function switchSetupTab(t) {
document.querySelectorAll('.setup-tab').forEach((el,i) => el.classList.toggle('active', i===(t==='navidrome'?0:1)));
document.querySelectorAll('.setup-pane').forEach((el,i) => el.classList.toggle('active', i===(t==='navidrome'?0:1)));
}
// ─── SETTINGS ───
export function openSettings() {
// Lazy import to avoid circular dep — abs.js imports views.js
import('./abs.js').then(m => m.absOpenSettings());
document.getElementById('s-url').value = S.cfg.url || '';
document.getElementById('s-user').value = S.cfg.user || '';
document.getElementById('s-pass').value = '';
document.getElementById('s-lastfm').value = S.cfg.lastfmKey || '';
document.getElementById('s-backend').value = S.cfg.backendUrl || '';
document.getElementById('s-show-playcount').classList.toggle('on', !!S.cfg.showPlayCount);
_renderThemePicker();
document.getElementById('settings-modal').classList.add('open');
checkBackend();
}
function _renderThemePicker() {
const el = document.getElementById('theme-picker');
if (!el) return;
const current = getCurrentThemeId();
el.innerHTML = THEMES.map(t => `
<div class="theme-swatch${t.id === current ? ' active' : ''}" data-id="${t.id}" onclick="_pickTheme('${t.id}')" style="--sw-bg:${t.bg};--sw-accent:${t.accent}">
<div class="theme-swatch-dot"></div>
<span>${esc(t.name)}</span>
</div>`).join('');
}
export function _pickTheme(id) {
applyTheme(id);
_renderThemePicker();
}
export function closeSettings() { document.getElementById('settings-modal').classList.remove('open'); }
export async function confirmDisconnect() {
const ok = await showConfirm('Abmelden', 'Wirklich abmelden? Alle lokalen Einstellungen werden gelöscht.', 'Abmelden');
if (ok) { closeSettings(); window.disconnect(); }
}
export function saveSettings() {
const pass = document.getElementById('s-pass').value;
S.cfg.url = document.getElementById('s-url').value.trim().replace(/\/$/,'');
S.cfg.user = document.getElementById('s-user').value.trim();
if(pass) S.cfg.pass = pass;
S.cfg.lastfmKey = document.getElementById('s-lastfm').value.trim();
S.cfg.backendUrl = document.getElementById('s-backend').value.trim().replace(/\/$/,'');
S.cfg.showPlayCount = document.getElementById('s-show-playcount').classList.contains('on');
localStorage.setItem('ns_cfg', JSON.stringify(S.cfg));
closeSettings(); toast('✅ Gespeichert');
}
export async function checkBackend() {
const dot = document.getElementById('backend-status-dot');
const txt = document.getElementById('backend-status-text');
const url = document.getElementById('s-backend').value.trim() || S.cfg.backendUrl;
if(!url) { dot.className='status-dot'; txt.textContent='Keine URL konfiguriert'; return; }
dot.className='status-dot'; txt.textContent='Verbinde…';
try {
const r = await fetch(`${url}/api/health`, {signal: AbortSignal.timeout(4000)});
const d = await r.json();
dot.className='status-dot ok';
txt.textContent = `Verbunden · yt-dlp ${d.ytdlpVersion||'OK'} · ${d.musicPath||''}`;
} catch(e) { dot.className='status-dot err'; txt.textContent='Nicht erreichbar: '+e.message; }
}
// ─── CONTEXT MENU ───
export function showCtxMenu(e, song, list, idx) {
const menu = document.getElementById('ctx-menu');
menu.innerHTML = '';
const items = [
{ icon: '▶', label: 'Abspielen', fn: () => playQueue(list, idx) },
{ icon: '+', label: 'Als Nächstes einfügen', fn: () => {
const insertIdx = S.qIdx + 1;
S.queue.splice(insertIdx, 0, {...song});
nativeInsertAt(song, insertIdx);
renderQueue(); toast('In Warteschlange eingefügt');
}},
{ icon: '⬇', label: 'Am Ende der Queue', fn: () => {
S.queue.push({...song});
nativeInsertAt(song, -1);
renderQueue(); toast('Zur Queue hinzugefügt');
}},
{ sep: true },
{ icon: '🎶', label: 'Zu Playlist hinzufügen →', submenu: true, fn: () => openPlModal([song]) },
{ icon: '⭐', label: song._starred ? 'Aus Favoriten entfernen' : 'Zu Favoriten', fn: () => {
api(song._starred ? 'unstar' : 'star', `&id=${song.id}`)
.then(() => { song._starred = !song._starred; toast(song._starred ? '⭐ Favorit hinzugefügt' : 'Favorit entfernt'); })
.catch(err => toast('Fehler: ' + err.message));
}},
{ sep: true },
{ icon: '💿', label: 'Zum Album', fn: () => song.albumId && navigate('album-detail', { id: song.albumId, name: song.album||'', artist: song.artist||'', artistId: song.artistId||'', coverArt: song.coverArt||'' }) },
{ icon: 'ℹ', label: 'Song-Info', fn: () => showSongInfo(song) },
{ icon: '⬇', label: 'Herunterladen', fn: () => dlSongLocal(song) },
];
items.forEach(item => {
if(item.sep) { const d=document.createElement('div'); d.className='ctx-separator'; menu.appendChild(d); return; }
const el = document.createElement('div'); el.className = 'ctx-item';
el.innerHTML = `<span style="font-size:14px;width:18px;text-align:center">${item.icon}</span><span>${item.label}</span>`;
el.onclick = () => { hideCtxMenu(); item.fn(); };
menu.appendChild(el);
});
const vw=window.innerWidth, vh=window.innerHeight;
let x=e.clientX, y=e.clientY;
menu.style.left='-9999px'; menu.style.top='-9999px'; menu.style.display='block';
const w=menu.offsetWidth, h=menu.offsetHeight;
if(x+w>vw-8) x=vw-w-8;
if(y+h>vh-8) y=vh-h-8;
menu.style.left=x+'px'; menu.style.top=y+'px';
}
export function hideCtxMenu() {
const m = document.getElementById('ctx-menu');
if (!m) return;
m.style.display='none'; m.innerHTML='';
}
document.addEventListener('click', e => {
const m = document.getElementById('ctx-menu');
if (m && !m.contains(e.target)) hideCtxMenu();
});
document.addEventListener('scroll', () => {
const m = document.getElementById('ctx-menu');
if (m && m.style.display !== 'none') hideCtxMenu();
}, { passive: true, capture: true });
document.addEventListener('touchmove', () => {
const m = document.getElementById('ctx-menu');
if (m && m.style.display !== 'none') hideCtxMenu();
}, { passive: true, capture: true });
document.addEventListener('keydown', e => { if(e.key==='Escape') hideCtxMenu(); });
// ─── PLAYLIST MODAL (Add to / Create) ───
export async function openPlModal(songs) {
S.plModalSongs = songs;
document.getElementById('pl-modal-title').textContent =
songs.length===1 ? `"${songs[0].title}" zu Playlist` : `${songs.length} Songs zu Playlist`;
document.getElementById('pl-new-name').value = '';
const list = document.getElementById('pl-list');
list.innerHTML = '<div style="font-size:12px;color:var(--text3);padding:8px">Lädt…</div>';
document.getElementById('pl-modal').classList.add('open');
try {
const r = await api('getPlaylists');
S.cachedPlaylists = r.playlists?.playlist || [];
list.innerHTML = '';
if(!S.cachedPlaylists.length) {
list.innerHTML = '<div style="font-size:12px;color:var(--text3);padding:8px">Noch keine Playlists vorhanden</div>';
return;
}
S.cachedPlaylists.forEach(pl => {
const row = document.createElement('div'); row.className = 'pl-row';
row.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/></svg>
<span>${esc(pl.name)}</span><span style="margin-left:auto;font-size:11px;color:var(--text3)">${pl.songCount} Titel</span>`;
row.onclick = () => addSongsToPlaylist(pl.id, pl.name);
list.appendChild(row);
});
} catch(e) { list.innerHTML = `<div style="color:var(--danger);font-size:12px;padding:8px">${e.message}</div>`; }
}
export function closePlModal() {
document.getElementById('pl-modal').classList.remove('open');
S.plModalSongs = [];
}
export async function addSongsToPlaylist(plId, plName) {
const songs = [...S.plModalSongs];
closePlModal();
try {
const ids = songs.map(s => `&songIdToAdd=${s.id}`).join('');
await api('updatePlaylist', `&playlistId=${plId}${ids}`);
toast(`✅ ${songs.length===1 ? songs[0].title : songs.length+' Songs'} → ${plName}`);
} catch(e) { toast('Fehler: ' + e.message); }
}
export async function createAndAddPlaylist() {
const name = document.getElementById('pl-new-name').value.trim();
if(!name) { document.getElementById('pl-new-name').focus(); return; }
closePlModal();
try {
const ids = S.plModalSongs.map(s => `&songId=${s.id}`).join('');
await api('createPlaylist', `&name=${encodeURIComponent(name)}${ids}`);
toast(`✅ Playlist "${name}" erstellt`);
if(document.getElementById('page-playlists').classList.contains('active')) loadPlaylists();
} catch(e) { toast('Fehler: ' + e.message); }
}
export function createPlaylistPrompt() {
document.getElementById('pl-new-name').value = '';
S.plModalSongs = [];
document.getElementById('pl-modal-title').textContent = 'Neue Playlist erstellen';
document.getElementById('pl-list').innerHTML = '<div style="font-size:12px;color:var(--text3);padding:8px 0">Füge Songs später per Rechtsklick hinzu.</div>';
document.getElementById('pl-modal').classList.add('open');
}
document.addEventListener('click', e => {
const m = document.getElementById('pl-modal');
if(m.classList.contains('open') && e.target===m) closePlModal();
});
// ─── SONG INFO MODAL ───
// Normalize various artist separators (•, ;, ,) for display in the edit form.
// Track artist → "Serph; Zun" | Album artist → "Serph, Zun"
function _fmtArtists(str, sep) {
if (!str) return '';
return str.split(/\s*[•·;,]\s*/).map(s => s.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],
['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) {
// getSong often omits albumArtist — fill it from getAlbum which always has album.artist
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 edit mode was already opened before getSong returned, refresh the fields
// so the form shows the authoritative data (albumArtist, etc.) from Navidrome
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');
// Populate fields from current song
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 || '';
// 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,
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,
};
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; }
S.downloadTarget = track; S.dlType = 'track';
document.querySelectorAll('.modal-opt').forEach(o => o.classList.toggle('selected', o.dataset.type==='track'));
const info = document.getElementById('dl-modal-info');
const art = track.artworkUrl100 || track.artworkUrl60 || '';
info.innerHTML = `${art ? `<div class="modal-art"><img src="${art}"></div>` : '<div class="modal-art" style="display:flex;align-items:center;justify-content:center;font-size:28px">🎵</div>'}
<div class="qi-info"><div class="mti-name">${esc(track.trackName||track.collectionName||'—')}</div><div class="mti-sub">${esc(track.artistName||'—')}${track.collectionName?' · '+esc(track.collectionName):''}</div></div>`;
document.getElementById('dl-album-sub').textContent = track.collectionName ? `"${track.collectionName}" herunterladen` : 'Album herunterladen';
document.getElementById('download-modal').classList.add('open');
}
export function closeDlModal() { document.getElementById('download-modal').classList.remove('open'); }
export function selectDlType(el, type) {
S.dlType = type;
document.querySelectorAll('.modal-opt').forEach(o => o.classList.remove('selected'));
el.classList.add('selected');
}
export async function executeDownload() {
if(!S.downloadTarget || !S.cfg.backendUrl) return;
closeDlModal();
const jobId = 'job_' + Date.now();
const payload = {
type: S.dlType,
artist: S.downloadTarget.artistName || 'Unknown Artist',
title: S.downloadTarget.trackName || S.downloadTarget.collectionName || 'Unknown',
album: S.downloadTarget.collectionName || '',
artworkUrl: S.downloadTarget.artworkUrl100 || S.downloadTarget.artworkUrl60 || '',
itunesId: S.downloadTarget.trackId || S.downloadTarget.collectionId || null,
collectionId: S.downloadTarget.collectionId || null,
explicit: S.downloadTarget.trackExplicitness === 'explicit',
trackNum: S.downloadTarget.trackNumber || undefined,
totalTracks: S.downloadTarget.trackCount || undefined,
discNum: S.downloadTarget.discNumber || undefined,
discTotal: S.downloadTarget.discCount || undefined,
durationMs: S.downloadTarget.trackTimeMillis || undefined,
};
addJob(jobId, payload.title, payload.artist, payload.type, payload.album);
try {
const res = await fetch(`${S.cfg.backendUrl}/api/download`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({...payload, jobId}),
});
if(!res.ok) throw new Error(`HTTP ${res.status}`);
pollJob(jobId);
} catch(e) {
updateJob(jobId, 'error', 'Verbindung zum Backend fehlgeschlagen');
toast('❌ Backend nicht erreichbar: '+e.message);
}
}
// ─── SPOTIFY DOWNLOAD ───
export async function executeSpotifyDownload({ type, title, artist, artworkUrl, tracks, createPlaylist, playlistName }) {
if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; }
const jobId = 'job_' + Date.now();
addJob(jobId, title, artist, type);
try {
const res = await fetch(`${S.cfg.backendUrl}/api/download`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jobId, type, title, artist, artworkUrl, tracks }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
pollJob(jobId);
if (createPlaylist && playlistName && tracks?.length) {
_pollAndCreatePlaylist(jobId, tracks, playlistName);
}
} catch(e) {
updateJob(jobId, 'error', 'Backend-Verbindung fehlgeschlagen: ' + e.message);
toast('❌ Backend nicht erreichbar: ' + e.message);
}
}
async function _pollAndCreatePlaylist(jobId, tracks, playlistName) {
// Track last known status separately — job is deleted from S.jobs 5s after completion,
// so S.jobs[jobId] may already be gone by the time we check after the loop breaks.
let lastStatus = 'pending';
while (true) {
await new Promise(r => setTimeout(r, 2500));
const job = S.jobs[jobId];
if (job) lastStatus = job.status;
if (!job || job.status === 'done' || job.status === 'error') break;
}
if (lastStatus !== 'done') return;
// Trigger Navidrome library scan so newly downloaded files are indexed before searching.
// Initial 3s delay lets the scan actually start before we poll.
// 5s buffer after completion lets DB writes settle before we search.
// If scan API fails entirely, fall back to a 30s fixed wait.
try {
await api('startScan', '');
toast('🔍 Navidrome scannt Bibliothek…');
await new Promise(r => setTimeout(r, 3000));
for (let i = 0; i < 30; i++) {
await new Promise(r => setTimeout(r, 2000));
const st = await api('getScanStatus', '');
if (!st?.scanStatus?.scanning) break;
}
await new Promise(r => setTimeout(r, 3000));
} catch {
await new Promise(r => setTimeout(r, 20000));
}
await _createNdPlaylist(tracks, playlistName);
}
async function _createNdPlaylist(tracks, name) {
try {
toast('🎵 Erstelle Navidrome-Playlist…');
const songIds = [];
const missing = [];
const norm = s => s?.toLowerCase().trim() || '';
// Strip non-word, non-space chars for loose artist first-word comparison
const normA = s => norm(s).replace(/[^\w\s]/g, '');
for (const t of tracks) {
if (!t.trackName) continue;
const missingLabel = t.artistName ? t.artistName + ' – ' + t.trackName : t.trackName;
try {
const firstArtist = t.artistName ? t.artistName.split(/[,/•]/)[0].trim() : '';
const artistQuery = firstArtist ? firstArtist + ' ' + t.trackName : t.trackName;
const r = await api('search3', '&query=' + encodeURIComponent(artistQuery) + '&songCount=20&albumCount=0&artistCount=0');
let songs = r.searchResult3?.song || [];
// If artist+title query returned no title match (e.g. special chars in artist like 'Kløn'),
// retry with title-only so the track can still be found
if (!songs.some(s => norm(s.title) === norm(t.trackName))) {
try {
const r2 = await api('search3', '&query=' + encodeURIComponent(t.trackName) + '&songCount=20&albumCount=0&artistCount=0');
songs = r2.searchResult3?.song || [];
} catch { /* keep original songs */ }
}
const firstWord = s => normA(s).split(/\s+/)[0];
const titleMatch = s => norm(s.title) === norm(t.trackName);
const artistMatch = s => firstWord(s.artist) === firstWord(firstArtist || t.artistName);
const match = songs.find(s => titleMatch(s) && artistMatch(s))
|| songs.find(s => titleMatch(s));
if (match) songIds.push(match.id);
else missing.push(missingLabel);
} catch { missing.push(missingLabel); }
}
if (!songIds.length) {
toast('⚠ Keine Tracks in Navidrome gefunden — zuerst Library scannen, dann Playlist manuell anlegen');
_addNotif({ id: 'pl_' + Date.now(), title: 'Playlist: ' + name, artist: '', status: 'error', message: 'Keine Tracks in Navidrome gefunden — Library scan nötig', missing, timestamp: Date.now(), read: false });
return;
}
const songParams = songIds.map(id => '&songId=' + encodeURIComponent(id)).join('');
const plRes = await api('createPlaylist', '&name=' + encodeURIComponent(name) + songParams);
const plId = plRes.playlist?.id || null;
const msg = songIds.length + '/' + tracks.length + ' Tracks hinzugefügt' + (missing.length ? ', ' + missing.length + ' nicht gefunden' : '');
toast('✅ Playlist "' + name + '" mit ' + songIds.length + '/' + tracks.length + ' Tracks erstellt');
_addNotif({ id: 'pl_' + Date.now(), title: 'Playlist: ' + name, artist: '', status: 'done', message: msg, missing, timestamp: Date.now(), read: false, navType: 'playlist', navId: plId, navName: name });
} catch(e) {
toast('Playlist-Erstellung fehlgeschlagen: ' + e.message);
}
}
// ─── NOTIFICATIONS ───
const _NOTIF_KEY = 'ns_notifications';
function _loadNotifs() {
try { return JSON.parse(localStorage.getItem(_NOTIF_KEY) || '[]'); } catch { return []; }
}
function _saveNotifs(n) { localStorage.setItem(_NOTIF_KEY, JSON.stringify(n.slice(0, 100))); }
function _addNotif(notif) {
const list = _loadNotifs();
list.unshift(notif);
_saveNotifs(list);
_renderNotifBell();
_renderNotifList();
}
export function deleteNotif(id) {
_saveNotifs(_loadNotifs().filter(n => n.id !== id));
_renderNotifBell();
_renderNotifList();
}
export function clearNotifs() {
_saveNotifs([]);
_renderNotifBell();
_renderNotifList();
}
export function toggleNotifPanel() {
const panel = document.getElementById('notif-panel');
const overlay = document.getElementById('notif-overlay');
if (!panel) return;
const isOpen = panel.classList.contains('open');
if (isOpen) {
panel.classList.remove('open');
overlay?.classList.remove('active');
} else {
_saveNotifs(_loadNotifs().map(n => ({...n, read: true})));
_renderNotifList();
_renderNotifBell();
panel.classList.add('open');
overlay?.classList.add('active');
}
}
export function initNotifBell() { _renderNotifBell(); }
function _renderNotifBell() {
const unread = _loadNotifs().filter(n => !n.read).length;
document.querySelectorAll('.notif-badge').forEach(b => {
b.textContent = unread > 9 ? '9+' : unread;
b.style.display = unread > 0 ? '' : 'none';
});
}
function _renderNotifList() {
const list = document.getElementById('notif-list');
if (!list) return;
const notifs = _loadNotifs();
if (!notifs.length) {
list.innerHTML = '<div class="notif-empty">Keine Benachrichtigungen</div>';
return;
}
list.innerHTML = notifs.map(n => {
const d = new Date(n.timestamp);
const time = d.toLocaleDateString('de-DE', {day:'2-digit', month:'2-digit'}) + ' ' + d.toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit'});
const icon = n.status === 'done' ? '✅' : '❌';
const failedHtml = n.failed?.length
? `<details class="notif-failed"><summary>${n.failed.length} Track${n.failed.length > 1 ? 's' : ''} fehlgeschlagen</summary><ul>${n.failed.map(f => `<li>${esc(f)}</li>`).join('')}</ul></details>`
: '';
const missingHtml = n.missing?.length
? `<details class="notif-failed"><summary>${n.missing.length} Track${n.missing.length > 1 ? 's' : ''} nicht in Navidrome gefunden</summary><ul>${n.missing.map(f => `<li>${esc(f)}</li>`).join('')}</ul></details>`
: '';
const hasNav = n.status === 'done' && ((n.navType === 'playlist' && n.navId) || n.navArtist || n.navAlbum);
const navLabel = n.navType === 'artist' ? 'Zum Künstler →'
: (n.navType === 'track' || n.navType === 'album') ? 'Zum Album →'
: 'Öffnen →';
const navBtn = hasNav
? `<button class="notif-nav-btn" onclick="_notifNavigate('${esc(n.id)}')">${navLabel}</button>`
: '';
return `<div class="notif-card${n.read ? '' : ' unread'}">
<div class="notif-card-top">
<span class="notif-icon">${icon}</span>
<div class="notif-info">
<div class="notif-title">${esc(n.title)}</div>
<div class="notif-sub">${n.artist ? esc(n.artist) + ' · ' : ''}${time}</div>
</div>
<button class="notif-x" onclick="deleteNotif('${esc(n.id)}')">✕</button>
</div>
${n.message ? `<div class="notif-msg">${esc(n.message)}</div>` : ''}
${failedHtml}
${missingHtml}
${navBtn}
</div>`;
}).join('');
}
export async function _notifNavigate(notifId) {
const notifs = _loadNotifs();
const n = notifs.find(x => x.id === notifId);
if (!n) return;
// close panel first
const panel = document.getElementById('notif-panel');
const overlay = document.getElementById('notif-overlay');
panel?.classList.remove('open');
overlay?.classList.remove('active');
if (n.navType === 'playlist' && n.navId) {
navigate('playlist-detail', { id: n.navId, name: n.navName || n.title.replace(/^Playlist:\s*/, '') });
return;
}
const norm = s => s?.toLowerCase().trim() || '';
// Track or album download → find album in Navidrome and navigate there.
if (n.navType === 'track' || n.navType === 'album') {
const albumQuery = n.navAlbum || n.navTitle;
if (albumQuery) {
try {
const r = await api('search3', `&query=${encodeURIComponent(albumQuery)}&artistCount=0&albumCount=10&songCount=0`);
const albums = r.searchResult3?.album || [];
// Prefer exact album + artist match, fall back to album-name-only match.
const match = albums.find(a => norm(a.name) === norm(albumQuery) && norm(a.artist) === norm(n.navArtist))
|| albums.find(a => norm(a.name) === norm(albumQuery))
|| albums[0];
if (match) {
navigate('album-detail', { id: match.id, name: match.name, artist: match.artist || '', artistId: match.artistId || '', coverArt: match.coverArt || '' });
return;
}
} catch {}
}
toast('Album nicht gefunden — Library-Scan nötig?');
return;
}
if (n.navArtist) {
try {
const r = await api('search3', `&query=${encodeURIComponent(n.navArtist)}&artistCount=5&albumCount=0&songCount=0`);
const artists = r.searchResult3?.artist || [];
const match = artists.find(a => norm(a.name) === norm(n.navArtist)) || artists[0];
if (match) { navigate('artist-detail', { id: match.id, name: match.name }); return; }
} catch {}
toast('Künstler nicht gefunden — Library-Scan nötig?');
}
}
function _parseFailedTracks(log) {
const re = /Fehler bei "([^"]+)"/;
return (log || []).map(line => re.exec(line)?.[1]).filter(Boolean);
}
// ─── JOBS ───
function addJob(id, title, artist, type, album) {
S.jobs[id] = {title, artist, type: type || '', album: album || '', progress:0, status:'pending'};
renderJobs();
}
function updateJob(id, status, msg, progress) {
if(!S.jobs[id]) return;
S.jobs[id] = {...S.jobs[id], status, msg:msg||'', progress: progress ?? S.jobs[id].progress};
renderJobs();
if(status==='done') setTimeout(() => { delete S.jobs[id]; renderJobs(); }, 5000);
if(status==='error') setTimeout(() => { delete S.jobs[id]; renderJobs(); }, 8000);
}
function renderJobs() {
const panel = document.getElementById('jobs-panel');
const activeIds = new Set(Object.keys(S.jobs));
// Remove cards whose jobs are gone
panel.querySelectorAll('[data-job-id]').forEach(el => {
if (!activeIds.has(el.dataset.jobId)) el.remove();
});
Object.entries(S.jobs).forEach(([id, j]) => {
let c = panel.querySelector(`[data-job-id="${id}"]`);
if (!c) {
c = document.createElement('div');
c.className = 'job-card';
c.dataset.jobId = id;
panel.appendChild(c);
}
const isDone = j.status === 'done', isErr = j.status === 'error';
const statusText = isErr ? 'Fehler' : isDone ? '✓ Fertig! Bibliothek wird gescannt…' : j.msg || 'Wird heruntergeladen…';
c.innerHTML = `<div class="job-header">
<div class="job-title">${esc(j.title)}</div>
<div class="job-status">${esc(j.artist)}</div>
</div>
<div class="job-bar"><div class="job-fill ${isDone?'done':isErr?'error':''}" style="width:${isDone?100:isErr?100:j.progress}%"></div></div>
<div class="job-sub">${statusText}</div>`;
});
}
async function pollJob(jobId) {
if(!S.jobs[jobId]) return;
try {
const res = await fetch(`${S.cfg.backendUrl}/api/status/${jobId}`);
const d = await res.json();
updateJob(jobId, d.status, d.message, d.progress);
if(d.status !== 'done' && d.status !== 'error') {
setTimeout(() => pollJob(jobId), 1500);
} else {
const job = S.jobs[jobId];
const title = job?.title || 'Download';
_addNotif({
id: jobId,
title,
artist: job?.artist || '',
status: d.status,
message: d.message || (d.status === 'done' ? 'Fertig!' : 'Fehler'),
failed: _parseFailedTracks(d.log),
timestamp: Date.now(),
read: false,
navType: job?.type || '',
navArtist: job?.artist || '',
navAlbum: job?.album || '',
navTitle: title,
});
if (d.status === 'done') {
toast(d.skipped ? `⏭ "${title}" bereits auf dem Server` : `✅ ${title} gespeichert!`);
}
}
} catch { updateJob(jobId, 'error', 'Status-Abfrage fehlgeschlagen'); }
}
// ─── EDIT PLAYLIST MODAL ───
export function openEditPlModal(pl) {
const overlay = document.getElementById('edit-pl-overlay');
document.getElementById('edit-pl-name').value = pl.name || '';
document.getElementById('edit-pl-desc').value = pl.comment || '';
const tog = document.getElementById('edit-pl-public-toggle');
tog.classList.toggle('on', !!pl.public);
const cv = coverUrl(pl.coverArt, 300);
document.getElementById('edit-pl-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:36px">🎶</div>';
overlay.classList.add('open');
}
export function closeEditPlModal() { document.getElementById('edit-pl-overlay').classList.remove('open'); }
export async function saveEditPlaylist() {
const pl = S._currentPlaylist; if(!pl) return;
const name = document.getElementById('edit-pl-name').value.trim();
if(!name) { toast('Name darf nicht leer sein'); return; }
const comment = document.getElementById('edit-pl-desc').value.trim();
const pub = document.getElementById('edit-pl-public-toggle').classList.contains('on');
const btn = document.querySelector('.edit-pl-btn-save');
btn.disabled=true; btn.textContent='Speichern…';
try {
await api('updatePlaylist', `&playlistId=${pl.id}&name=${encodeURIComponent(name)}&comment=${encodeURIComponent(comment)}&public=${pub}`);
S._currentPlaylist = {...S._currentPlaylist, name, comment, public:pub};
document.getElementById('playlist-detail-name').textContent = name;
const descEl = document.getElementById('playlist-detail-desc');
if(descEl) { descEl.textContent=comment; descEl.style.display=comment?'':'none'; }
closeEditPlModal();
toast('Playlist aktualisiert');
} catch(e) { toast('Fehler: '+e.message); }
finally { btn.disabled=false; btn.textContent='Speichern'; }
}
export function editPlUploadCover() { document.getElementById('edit-pl-file').click(); }
export async function editPlCoverSelected(input) {
const file = input.files?.[0]; if(!file || !S._currentPlaylist) return;
input.value = '';
const preview = URL.createObjectURL(file);
document.getElementById('edit-pl-cover').innerHTML = `<img src="${preview}" style="width:100%;height:100%;object-fit:cover">`;
try {
const token = await ndAuth();
const form = new FormData(); form.append('image', file, file.name);
const res = await fetch(`${S.cfg.url}/api/playlist/${S._currentPlaylist.id}/image`, {
method: 'POST',
headers: {'X-Nd-Authorization': 'Bearer '+token},
body: form,
});
if(!res.ok) throw new Error(res.status);
toast('Cover aktualisiert');
setTimeout(() => {
const bust = coverUrl(S._currentPlaylist.coverArt, 300) + '&_cb=' + Date.now();
const detailImg = document.getElementById('playlist-cover');
if(detailImg) detailImg.innerHTML = `<img src="${bust}" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>🎶</div>'">`;
document.getElementById('edit-pl-cover').innerHTML = `<img src="${bust}" style="width:100%;height:100%;object-fit:cover">`;
}, 800);
} catch(e) {
S._ndToken = null;
toast('Cover-Upload fehlgeschlagen: '+e.message);
const cv = coverUrl(S._currentPlaylist.coverArt, 300);
document.getElementById('edit-pl-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:36px">🎶</div>';
}
}
export async function editPlRemoveCover() {
if(!S._currentPlaylist) return;
try {
const token = await ndAuth();
const res = await fetch(`${S.cfg.url}/api/playlist/${S._currentPlaylist.id}/image`, {
method: 'DELETE', headers: {'X-Nd-Authorization': 'Bearer '+token},
});
if(!res.ok) throw new Error(res.status);
toast('Cover entfernt');
S._currentPlaylist.coverArt = null;
document.getElementById('edit-pl-cover').innerHTML = '<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:36px">🎶</div>';
document.getElementById('playlist-cover').innerHTML = '<div class="no-art">🎶</div>';
} catch(e) { S._ndToken = null; toast('Fehler: '+e.message); }
}
let _confirmResolve = null;
export function _confirmOk() { document.getElementById('confirm-overlay').classList.remove('open'); _confirmResolve?.(true); _confirmResolve = null; }
export function _confirmCancel() { document.getElementById('confirm-overlay').classList.remove('open'); _confirmResolve?.(false); _confirmResolve = null; }
function showConfirm(title, msg, okLabel = 'Bestätigen') {
document.getElementById('confirm-title').textContent = title;
document.getElementById('confirm-msg').textContent = msg;
document.getElementById('confirm-ok-btn').textContent = okLabel;
document.getElementById('confirm-overlay').classList.add('open');
return new Promise(resolve => { _confirmResolve = resolve; });
}
export async function deletePlaylist() {
const pl = S._currentPlaylist;
if (!pl) return;
const ok = await showConfirm('Playlist löschen', `"${pl.name}" wird unwiderruflich gelöscht.`, 'Löschen');
if (!ok) return;
try {
await api('deletePlaylist', `&id=${pl.id}`);
closeEditPlModal();
toast(`🗑 "${pl.name}" gelöscht`);
navigate('playlists');
} catch(e) { toast('Fehler beim Löschen: ' + e.message); }
}