// ============================================ // SoulSync Setup Wizard — First-Run Experience // ============================================ const WIZARD_STEPS = ['welcome', 'metadata', 'download-source', 'paths', 'watchlist', 'first-download', 'done']; let _wizardStep = 0; let _wizardSettings = { metadata_source: 'deezer', download_source: 'soulseek', slskd_url: '', slskd_api_key: '', download_path: '/app/downloads', transfer_path: '/app/Transfer', media_server: 'none', server_url: '', server_token: '', server_user: '', server_pass: '', server_api_key: '', // Tidal/Qobuz/Deezer download creds tidal_client_id: '', tidal_client_secret: '', qobuz_quality: 'lossless', deezer_arl: '', }; let _wizardAddedArtists = []; // [{id, name, image}] let _wizardDownloadedTrack = null; let _wizardSearchTimeout = null; let _wizardPathLocks = { download: true, transfer: true }; // ---- Open / Close ---- function openSetupWizard() { const overlay = document.getElementById('setup-wizard-overlay'); if (!overlay) return; overlay.style.display = 'flex'; _wizardStep = 0; _wizardSettings = { metadata_source: 'deezer', download_source: 'soulseek', slskd_url: '', slskd_api_key: '', download_path: '/app/downloads', transfer_path: '/app/Transfer', media_server: 'none', server_url: '', server_token: '', server_user: '', server_pass: '', server_api_key: '', tidal_client_id: '', tidal_client_secret: '', qobuz_quality: 'lossless', deezer_arl: '', }; _wizardAddedArtists = []; _wizardDownloadedTrack = null; _wizardPathLocks = { download: true, transfer: true }; _renderWizard(); } function closeSetupWizard() { const overlay = document.getElementById('setup-wizard-overlay'); if (overlay) overlay.style.display = 'none'; // Mark as complete so it doesn't show again (server + client) localStorage.setItem('soulsync_setup_complete', 'true'); fetch('/api/setup/complete', { method: 'POST' }).catch(() => {}); // Continue app initialization if wizard was shown on first run if (typeof window._onSetupWizardComplete === 'function') { window._onSetupWizardComplete(); window._onSetupWizardComplete = null; } } // ---- Navigation ---- function wizardNext() { if (!_validateWizardStep()) return; // Save settings for the current step before advancing _saveWizardStepSettings(); if (_wizardStep < WIZARD_STEPS.length - 1) { _wizardStep++; _renderWizard(); } } function wizardBack() { if (_wizardStep > 0) { _wizardStep--; _renderWizard(); } } function wizardSkipStep() { if (_wizardStep < WIZARD_STEPS.length - 1) { _wizardStep++; _renderWizard(); } } // ---- Validation ---- function _validateWizardStep() { const step = WIZARD_STEPS[_wizardStep]; if (step === 'download-source' && _wizardSettings.download_source === 'soulseek') { if (!_wizardSettings.slskd_url || !_wizardSettings.slskd_api_key) { if (typeof showToast === 'function') showToast('Please fill in the slskd URL and API key', 'error'); return false; } } return true; } // ---- Save settings per step to backend (same as settings page) ---- async function _saveWizardStepSettings() { const step = WIZARD_STEPS[_wizardStep]; const settings = {}; if (step === 'metadata') { settings.metadata = { fallback_source: _wizardSettings.metadata_source }; } else if (step === 'download-source') { settings.download_source = { mode: _wizardSettings.download_source }; if (_wizardSettings.download_source === 'soulseek' || _wizardSettings.slskd_url) { settings.soulseek = { slskd_url: _wizardSettings.slskd_url, api_key: _wizardSettings.slskd_api_key, }; } if (_wizardSettings.download_source === 'tidal') { settings.tidal = { client_id: _wizardSettings.tidal_client_id, client_secret: _wizardSettings.tidal_client_secret, }; } if (_wizardSettings.download_source === 'deezer_dl') { settings.deezer_download = { arl: _wizardSettings.deezer_arl }; } } else if (step === 'paths') { settings.soulseek = Object.assign(settings.soulseek || {}, { download_path: _wizardSettings.download_path, transfer_path: _wizardSettings.transfer_path, }); if (_wizardSettings.media_server !== 'none') { settings.active_media_server = _wizardSettings.media_server; if (_wizardSettings.media_server === 'plex') { settings.plex = { base_url: _wizardSettings.server_url, token: _wizardSettings.server_token }; } else if (_wizardSettings.media_server === 'jellyfin') { settings.jellyfin = { base_url: _wizardSettings.server_url, api_key: _wizardSettings.server_api_key }; } else if (_wizardSettings.media_server === 'navidrome') { settings.navidrome = { base_url: _wizardSettings.server_url, username: _wizardSettings.server_user, password: _wizardSettings.server_pass }; } } } if (Object.keys(settings).length > 0) { try { await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) }); } catch (e) { console.error('Wizard step save error:', e); } } } // ---- Main Render ---- function _renderWizard() { const container = document.getElementById('setup-wizard-content'); if (!container) return; // Update stepper const stepper = document.getElementById('setup-wizard-stepper'); if (stepper) { stepper.innerHTML = WIZARD_STEPS.map((s, i) => { const dotClass = i === _wizardStep ? 'active' : (i < _wizardStep ? 'completed' : ''); const lineClass = i < _wizardStep ? 'completed' : ''; let html = `
`; if (i < WIZARD_STEPS.length - 1) html += `
`; return html; }).join(''); } // Render step content const step = WIZARD_STEPS[_wizardStep]; switch (step) { case 'welcome': _renderWelcome(container); break; case 'metadata': _renderMetadata(container); break; case 'download-source': _renderDownloadSource(container); break; case 'paths': _renderPaths(container); break; case 'watchlist': _renderWatchlist(container); break; case 'first-download': _renderFirstDownload(container); break; case 'done': _renderDone(container); break; } } // ---- Step 1: Welcome ---- function _renderWelcome(el) { el.innerHTML = `

Welcome to SoulSync

Intelligent Music Discovery & Automation

Search and download music from multiple sources
Sync playlists from Spotify, Tidal, Deezer and more
Discover new music with watchlist monitoring and smart caching
Organize and serve to Plex, Jellyfin, or Navidrome
`; } // ---- Step 2: Metadata Source ---- function _renderMetadata(el) { const sources = [ { id: 'deezer', name: 'Deezer', badge: 'Recommended', desc: 'No authentication required. Rich metadata with album art.' }, { id: 'spotify', name: 'Spotify', badge: '', desc: 'Requires API credentials. Best for playlist sync.' }, { id: 'itunes', name: 'iTunes', badge: '', desc: 'No authentication required. Apple Music catalog.' }, ]; el.innerHTML = `

Metadata Source

Where should SoulSync look up track info, album art, and metadata?

${sources.map(s => ` `).join('')}
`; } function _wizardSelectMetadata(id) { _wizardSettings.metadata_source = id; _renderWizard(); } // ---- Step 3: Download Source ---- function _renderDownloadSource(el) { const sources = [ { id: 'soulseek', name: 'Soulseek', desc: 'P2P network via slskd. Best quality and selection.', needsConfig: true }, { id: 'youtube', name: 'YouTube', desc: 'No setup required. Good availability.', needsConfig: false }, { id: 'hifi', name: 'HiFi', desc: 'No setup required. Lossless quality.', needsConfig: false }, { id: 'tidal', name: 'Tidal', desc: 'Requires Tidal credentials. Lossless streaming.', needsConfig: true }, { id: 'qobuz', name: 'Qobuz', desc: 'No setup required. Hi-res audio.', needsConfig: false }, { id: 'deezer_dl', name: 'Deezer', desc: 'Requires ARL token. FLAC downloads.', needsConfig: true }, ]; const sel = _wizardSettings.download_source; // Build inline config based on selected source let inlineConfig = ''; if (sel === 'soulseek') { inlineConfig = `
`; } else if (sel === 'tidal') { inlineConfig = `
`; } else if (sel === 'deezer_dl') { inlineConfig = `
`; } el.innerHTML = `

Download Source

Choose your primary download source. You can enable Hybrid mode later in Settings to try multiple sources automatically.

${sources.map(s => `
${s.name}
${s.desc}
`).join('')}
${inlineConfig}
`; } function _wizardSelectDownload(id) { _wizardSettings.download_source = id; _renderWizard(); } async function _wizardTestConnection(service) { // Find the test button in the current inline config const btns = document.querySelectorAll('.setup-test-btn'); const btn = btns[btns.length - 1]; if (btn) { btn.innerHTML = 'Testing...'; btn.className = 'setup-test-btn'; } // Save relevant settings first so the backend can test them const settings = {}; if (service === 'soulseek') { settings.soulseek = { slskd_url: _wizardSettings.slskd_url, api_key: _wizardSettings.slskd_api_key }; } else if (service === 'tidal') { settings.tidal = { client_id: _wizardSettings.tidal_client_id, client_secret: _wizardSettings.tidal_client_secret }; } else if (service === 'plex') { settings.plex = { base_url: _wizardSettings.server_url, token: _wizardSettings.server_token }; settings.active_media_server = 'plex'; } else if (service === 'jellyfin') { settings.jellyfin = { base_url: _wizardSettings.server_url, api_key: _wizardSettings.server_api_key }; settings.active_media_server = 'jellyfin'; } else if (service === 'navidrome') { settings.navidrome = { base_url: _wizardSettings.server_url, username: _wizardSettings.server_user, password: _wizardSettings.server_pass }; settings.active_media_server = 'navidrome'; } try { await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) }); const resp = await fetch('/api/test-connection', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ service }) }); const result = await resp.json(); if (btn) { if (result.success) { btn.textContent = 'Connected'; btn.classList.add('success'); } else { btn.textContent = 'Failed — check credentials'; btn.classList.add('failed'); } } } catch { if (btn) { btn.textContent = 'Connection error'; btn.classList.add('failed'); } } } // ---- Step 4: Paths & Media Server ---- function _renderPaths(el) { const server = _wizardSettings.media_server; const showServerConfig = server !== 'none'; let serverFields = ''; if (server === 'plex') { serverFields = `
`; } else if (server === 'jellyfin') { serverFields = `
`; } else if (server === 'navidrome') { serverFields = `
`; } const dlLocked = _wizardPathLocks.download; const trLocked = _wizardPathLocks.transfer; el.innerHTML = `

Paths & Media Server

Where should downloaded music go?

Connect a media server

${['plex', 'jellyfin', 'navidrome', 'none'].map(s => `
${s === 'none' ? 'None' : s.charAt(0).toUpperCase() + s.slice(1)}
`).join('')}
${serverFields}
`; } function _wizardTogglePathLock(pathType) { _wizardPathLocks[pathType] = !_wizardPathLocks[pathType]; _renderWizard(); if (!_wizardPathLocks[pathType]) { const id = pathType === 'download' ? 'setup-download-path' : 'setup-transfer-path'; const input = document.getElementById(id); if (input) input.focus(); } } function _wizardSelectServer(id) { _wizardSettings.media_server = id; _wizardSettings.server_url = ''; _wizardSettings.server_token = ''; _wizardSettings.server_user = ''; _wizardSettings.server_pass = ''; _wizardSettings.server_api_key = ''; _renderWizard(); } // ---- Step 5: Add Artists to Watchlist ---- function _renderWatchlist(el) { const chips = _wizardAddedArtists.map((a, i) => `
${_escHtml(a.name)} ×
`).join(''); el.innerHTML = `

Add Your First Artists

Search for artists to add to your watchlist. SoulSync will automatically download their new releases.

${chips}
`; } function _wizardArtistSearch(query) { clearTimeout(_wizardSearchTimeout); if (query.length < 2) { const results = document.getElementById('setup-artist-results'); if (results) results.innerHTML = ''; return; } _wizardSearchTimeout = setTimeout(async () => { const results = document.getElementById('setup-artist-results'); if (!results) return; results.innerHTML = '
Searching...
'; try { // Use the discover artist search endpoint — works with whatever metadata source is active const resp = await fetch(`/api/discover/build-playlist/search-artists?query=${encodeURIComponent(query)}`); const data = await resp.json(); const artists = data.artists || []; if (artists.length === 0) { results.innerHTML = '
No artists found
'; return; } // Check which artists are already in watchlist const artistIds = artists.map(a => String(a.id)); let watchlistStatus = {}; try { const wResp = await fetch('/api/watchlist/check-batch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ artist_ids: artistIds }) }); const wData = await wResp.json(); if (wData.success) watchlistStatus = wData.results || {}; } catch { /* ignore */ } results.innerHTML = artists.slice(0, 8).map((a, i) => { const img = a.image_url || ''; const name = a.name || ''; const id = String(a.id || ''); const isInWatchlist = watchlistStatus[id] || _wizardAddedArtists.some(w => String(w.id) === id); return `
${img ? `` : '
'}
${_escHtml(name)}
${isInWatchlist ? '✓ Watching' : '+ Add'}
`; }).join(''); } catch (e) { console.error('Wizard artist search error:', e); results.innerHTML = '
Search failed
'; } }, 400); } async function _wizardAddArtistDirect(id, name, image) { if (_wizardAddedArtists.some(a => String(a.id) === id)) return; try { const resp = await fetch('/api/watchlist/add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ artist_id: id, artist_name: name, image_url: image }) }); const result = await resp.json(); if (result.success || result.status === 'already_watching') { _wizardAddedArtists.push({ id, name, image }); // Re-render chips but preserve search results const chipsEl = document.getElementById('setup-added-artists'); if (chipsEl) { chipsEl.innerHTML = _wizardAddedArtists.map((a, i) => `
${_escHtml(a.name)} ×
`).join(''); } // Update the row in results to show "watching" const row = document.querySelector(`.setup-artist-row[data-artist-id="${id}"]`); if (row) { row.classList.add('added'); row.setAttribute('onclick', `_wizardRemoveArtistById('${_escHtml(id)}','${_escHtml(name)}')`); const check = row.querySelector('.setup-artist-check'); if (check) check.innerHTML = '✓ Watching'; } if (typeof showToast === 'function') showToast(`Added ${name} to watchlist`, 'success'); } else { if (typeof showToast === 'function') showToast(result.error || 'Failed to add artist', 'error'); } } catch (e) { console.error('Wizard add artist error:', e); } } async function _wizardRemoveArtistById(id, name) { try { await fetch('/api/watchlist/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ artist_id: id }) }); _wizardAddedArtists = _wizardAddedArtists.filter(a => String(a.id) !== id); // Re-render chips const chipsEl = document.getElementById('setup-added-artists'); if (chipsEl) { chipsEl.innerHTML = _wizardAddedArtists.map((a, i) => `
${_escHtml(a.name)} ×
`).join(''); } // Update the row in results const row = document.querySelector(`.setup-artist-row[data-artist-id="${id}"]`); if (row) { row.classList.remove('added'); const img = _wizardAddedArtists.find(a => String(a.id) === id)?.image || ''; row.setAttribute('onclick', `_wizardAddArtistDirect('${_escHtml(id)}','${_escHtml(name)}','${_escHtml(img)}')`); const check = row.querySelector('.setup-artist-check'); if (check) check.innerHTML = '+ Add'; } if (typeof showToast === 'function') showToast(`Removed ${name} from watchlist`, 'info'); } catch (e) { console.error('Wizard remove artist error:', e); } } function _wizardRemoveArtist(index) { const artist = _wizardAddedArtists[index]; if (artist) { _wizardRemoveArtistById(String(artist.id), artist.name); } } // ---- Step 6: First Download ---- function _renderFirstDownload(el) { el.innerHTML = `

Your First Download

Search for a track and download it to see SoulSync in action.

`; } function _wizardTrackSearch(query) { clearTimeout(_wizardSearchTimeout); if (query.length < 2) { const results = document.getElementById('setup-track-results'); if (results) results.innerHTML = ''; return; } _wizardSearchTimeout = setTimeout(async () => { const results = document.getElementById('setup-track-results'); if (!results) return; results.innerHTML = '
Searching...
'; try { // Use enhanced-search which searches the configured metadata source const resp = await fetch('/api/enhanced-search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query }) }); const data = await resp.json(); // Tracks are in spotify_tracks (backward compat key, actual source may be Deezer) const tracks = data.spotify_tracks || []; if (tracks.length === 0) { results.innerHTML = '
No tracks found
'; return; } // Store for download reference window._wizardTrackResults = tracks; results.innerHTML = tracks.slice(0, 8).map((t, i) => { const art = t.image_url || ''; const title = t.name || ''; const artist = t.artist || ''; const album = t.album || ''; return `
${art ? `` : '
'}
${_escHtml(title)}
${_escHtml(artist)}${album ? ' · ' + _escHtml(album) : ''}
Click to download
`; }).join(''); } catch (e) { console.error('Wizard track search error:', e); results.innerHTML = '
Search failed
'; } }, 400); } async function _wizardDownloadTrack(index) { const row = document.getElementById(`setup-track-${index}`); const status = document.getElementById(`setup-track-status-${index}`); if (!row || !status) return; if (row.classList.contains('downloading') || row.classList.contains('downloaded')) return; const tracks = window._wizardTrackResults; if (!tracks || !tracks[index]) return; const track = tracks[index]; row.classList.add('downloading'); status.innerHTML = 'Searching...'; try { // Step 1: Search for the best match via the configured download source const searchResp = await fetch('/api/enhanced-search/stream-track', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ track_name: track.name || '', artist_name: track.artist || '', album_name: track.album || '', duration_ms: track.duration_ms || 0, }) }); const searchResult = await searchResp.json(); if (!searchResult.success || !searchResult.result) { row.classList.remove('downloading'); status.textContent = searchResult.error || 'No match found'; return; } // Step 2: Start matched download with full metadata context from the search result status.innerHTML = 'Downloading...'; const artistName = track.artist || 'Unknown Artist'; const dlResp = await fetch('/api/download/matched', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ search_result: searchResult.result, spotify_artist: { name: artistName, id: track.artist_id || track.id || '', }, spotify_track: { name: track.name || '', artists: [artistName], album: { name: track.album || 'Unknown Album', images: track.image_url ? [{ url: track.image_url }] : [], }, track_number: track.track_number || 1, disc_number: track.disc_number || 1, duration_ms: track.duration_ms || 0, release_date: track.release_date || '', isrc: track.isrc || '', image_url: track.image_url || '', external_urls: track.external_urls || {}, }, is_single_track: true, }) }); const dlResult = await dlResp.json(); if (dlResult.success) { _wizardDownloadedTrack = track; row.classList.remove('downloading'); row.classList.add('downloaded'); status.textContent = 'Download started'; } else { row.classList.remove('downloading'); status.textContent = dlResult.error || 'Download failed'; } } catch (e) { console.error('Wizard download error:', e); row.classList.remove('downloading'); status.textContent = 'Error'; } } // ---- Step 7: Done ---- function _renderDone(el) { const summaryRows = []; const cap = s => s.charAt(0).toUpperCase() + s.slice(1); summaryRows.push({ label: 'Metadata Source', value: cap(_wizardSettings.metadata_source) }); const dlName = _wizardSettings.download_source === 'deezer_dl' ? 'Deezer' : cap(_wizardSettings.download_source); summaryRows.push({ label: 'Download Source', value: dlName }); if (_wizardSettings.download_path) summaryRows.push({ label: 'Download Folder', value: _wizardSettings.download_path }); if (_wizardSettings.transfer_path) summaryRows.push({ label: 'Music Library', value: _wizardSettings.transfer_path }); if (_wizardSettings.media_server !== 'none') summaryRows.push({ label: 'Media Server', value: cap(_wizardSettings.media_server) }); if (_wizardAddedArtists.length > 0) summaryRows.push({ label: 'Artists Added', value: _wizardAddedArtists.length.toString() }); el.innerHTML = `

You're All Set!

SoulSync is configured and ready to go.

${summaryRows.map(r => `
${r.label} ${_escHtml(r.value)}
`).join('')}
`; } async function _wizardFinish() { // Final save — all settings were saved per-step, but do a final pass const settings = { metadata: { fallback_source: _wizardSettings.metadata_source }, download_source: { mode: _wizardSettings.download_source }, soulseek: { download_path: _wizardSettings.download_path, transfer_path: _wizardSettings.transfer_path, }, }; if (_wizardSettings.slskd_url) { settings.soulseek.slskd_url = _wizardSettings.slskd_url; settings.soulseek.api_key = _wizardSettings.slskd_api_key; } if (_wizardSettings.media_server !== 'none') { settings.active_media_server = _wizardSettings.media_server; if (_wizardSettings.media_server === 'plex') { settings.plex = { base_url: _wizardSettings.server_url, token: _wizardSettings.server_token }; } else if (_wizardSettings.media_server === 'jellyfin') { settings.jellyfin = { base_url: _wizardSettings.server_url, api_key: _wizardSettings.server_api_key }; } else if (_wizardSettings.media_server === 'navidrome') { settings.navidrome = { base_url: _wizardSettings.server_url, username: _wizardSettings.server_user, password: _wizardSettings.server_pass }; } } try { await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) }); } catch (e) { console.error('Wizard final save error:', e); } // Mark setup complete on both server and client try { await fetch('/api/setup/complete', { method: 'POST' }); } catch (e) { console.error('Failed to mark setup complete on server:', e); } localStorage.setItem('soulsync_setup_complete', 'true'); const overlay = document.getElementById('setup-wizard-overlay'); if (overlay) overlay.style.display = 'none'; // Reload settings into the main UI if (typeof loadSettings === 'function') loadSettings(); if (typeof showToast === 'function') showToast('Setup complete — welcome to SoulSync!', 'success'); // Continue app initialization if wizard was shown on first run if (typeof window._onSetupWizardComplete === 'function') { window._onSetupWizardComplete(); window._onSetupWizardComplete = null; } } // ---- Utility ---- function _escHtml(str) { if (!str) return ''; return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } // ---- Dev Trigger ---- // Open wizard manually: openSetupWizard() from console, or ?setup=1 URL param // First-run auto-detection is handled in script.js DOMContentLoaded // Expose globally window.openSetupWizard = openSetupWizard; window.closeSetupWizard = closeSetupWizard;