import { S } from '../state.js'; import { esc, toast } from '../utils.js'; import { api, fetchRomajiIndex, romajiConvert, getRomajiOverrides, invalidateRomajiIndex, deezerLookupAlbum } from '../api.js'; import { _addNotif } from './notify.js'; // ─── DOWNLOAD MODAL ─── export function openDlModal(track) { if(!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert. Bitte in Einstellungen eintragen.'); return; } // Album cards pass isAlbum → default the modal to an album download, not a single track named // after the album (a one-click download of an album card used to grab it as one "track"). const _defType = track.isAlbum ? 'album' : 'track'; S.downloadTarget = track; S.dlType = _defType; document.querySelectorAll('.modal-opt').forEach(o => o.classList.toggle('selected', o.dataset.type===_defType)); const info = document.getElementById('dl-modal-info'); const art = track.artworkUrl100 || track.artworkUrl60 || ''; info.innerHTML = `${art ? `` : ''}
${esc(track.trackName||track.collectionName||'—')}
${esc(track.artistName||'—')}${track.collectionName?' · '+esc(track.collectionName):''}
`; 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 ov = getRomajiOverrides(); const _artist = S.downloadTarget.artistName || 'Unknown Artist'; const _title = S.downloadTarget.trackName || S.downloadTarget.collectionName || 'Unknown'; const _album = S.downloadTarget.collectionName || ''; const payload = { type: S.dlType, artist: _artist, title: _title, album: _album, artworkUrl: S.downloadTarget.artworkUrl100 || S.downloadTarget.artworkUrl60 || '', deezerId: 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.durationMs || S.downloadTarget.trackTimeMillis || undefined, ...(ov[_artist] && { artistSort: ov[_artist], albumArtistSort: ov[_artist] }), ...(ov[_title] && { titleSort: ov[_title] }), ...(ov[_album] && { albumSort: ov[_album] }), }; // Album download: fetch the album's tracks from Deezer (id in collectionId) and send them in the // request — the backend downloads that list directly (Apple's /search is 403, so no iTunes lookup). if (S.dlType === 'album' && payload.collectionId) { try { const albumTracks = await deezerLookupAlbum(payload.collectionId); if (albumTracks.length) payload.tracks = albumTracks; } catch {} } 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); const ov = getRomajiOverrides(); const enrichedTracks = (tracks || []).map(t => { const a = t.artistName || ''; const n = t.trackName || ''; const al = t.collectionName || ''; return { ...t, ...(ov[a] && { artistSort: ov[a], albumArtistSort: ov[a] }), ...(ov[n] && { titleSort: ov[n] }), ...(ov[al] && { albumSort: ov[al] }), }; }); 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: enrichedTracks }), }); 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); } } // Used by settings.js for romaji fix-sort job export async function startArtistSortFix(kanji, romaji) { if (!S.cfg.backendUrl || !S.cfg.url || !S.cfg.user || !S.cfg.pass) return; const jobId = 'job_' + Date.now(); addJob(jobId, `Romaji-Fix: ${romaji}`, kanji, 'fix-sort'); const res = await fetch(`${S.cfg.backendUrl}/api/fix-artist-sort`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ndUrl: S.cfg.url, user: S.cfg.user, pass: S.cfg.pass, artistKanji: kanji, artistSort: romaji, jobId }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); pollJob(jobId); } async function _pollAndCreatePlaylist(jobId, tracks, playlistName) { 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; 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() || ''; const normA = s => norm(s).replace(/[^\w\s]/g, ''); const JP_RE = /[぀-ヿ一-鿿]/; const hasJp = tracks.some(t => JP_RE.test(t.trackName) || JP_RE.test(t.artistName)); const romajiIdx = hasJp ? await fetchRomajiIndex().catch(() => null) : null; const jpTitles = tracks.map(t => (hasJp && JP_RE.test(t.trackName)) ? t.trackName : ''); const romajiTitles = hasJp ? await romajiConvert(jpTitles).catch(() => jpTitles) : jpTitles; const normR = s => (s || '').toLowerCase().replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim(); for (const [idx, t] of tracks.entries()) { 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 (!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); let match = songs.find(s => titleMatch(s) && artistMatch(s)) || songs.find(s => titleMatch(s)); if (!match && romajiIdx && JP_RE.test(t.trackName)) { const jpEntry = romajiIdx.find(e => norm(e.title) === norm(t.trackName)); if (jpEntry) match = { id: jpEntry.id }; } if (!match && JP_RE.test(t.trackName) && romajiTitles[idx]) { const rTitle = romajiTitles[idx]; try { const r3 = await api('search3', '&query=' + encodeURIComponent(rTitle) + '&songCount=20&albumCount=0&artistCount=0'); const s3 = r3.searchResult3?.song || []; match = s3.find(s => normR(s.title) === normR(rTitle)) || null; } catch { /* ignore */ } } 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); } } // ─── 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)); 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 = `
${esc(j.title)}
${esc(j.artist)}
${statusText}
`; }); } 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!`); if (!d.skipped) invalidateRomajiIndex(); } } } catch { updateJob(jobId, 'error', 'Status-Abfrage fehlgeschlagen'); } } function _parseFailedTracks(log) { const re = /Fehler bei "([^"]+)"/; return (log || []).map(line => re.exec(line)?.[1]).filter(Boolean); }