// ============================================
// 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 6 sources — Soulseek, YouTube, Tidal, Qobuz, HiFi, and Deezer
Mirror playlists from Spotify, Tidal, Deezer, YouTube, Beatport, and ListenBrainz
Watch artists and auto-download new releases as they drop
Organize your library and serve to Plex, Jellyfin, or Navidrome
Build automations that scan, download, and notify on your schedule
This wizard will walk you through the essentials. Everything can be changed later in Settings.
`;
}
// ---- 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?
What is a metadata source? When you search for music or sync a playlist, SoulSync needs a catalog to look up track names, artists, album art, track numbers, and release dates. This source provides that information — it does not affect where music is downloaded from.
How downloads work: When you search for a track, SoulSync uses your metadata source to identify it, then searches your download source for the actual audio file. The matching engine automatically finds the best quality match.
Hybrid mode (available later in Settings) lets you set a priority order — if your primary source doesn't have a track, SoulSync automatically tries the next source in line.
${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 = `
Two-folder system: Music downloads to the Input Folder first as raw files. After post-processing (metadata tagging, file organization), finished tracks are moved to the Output Folder organized into Artist/Album subfolders. Point your media server at the output folder.
Connect a media server
Connecting a media server lets SoulSync trigger library scans after downloads, import your existing library, and display what you already own when searching. Select None if you don't use one.
What is the Watchlist? Artists on your watchlist are monitored for new releases. When a watched artist drops a new album, EP, or single, it appears on your Discover page and can be auto-downloaded.
Discover page — populated by new releases from watched artists, similar artists, and seasonal picks
Watchlist Scanner — runs automatically on a schedule to check for new releases
Filters — per-artist controls for albums, EPs, singles, remixes, live recordings, and more
You can always add or remove artists later from the Artists page.
${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 = '
`).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
Try searching for a track to see the full pipeline in action.
How it works: Type a song name below. SoulSync searches your metadata source for the track, then finds the best matching audio file from your download source. The track is tagged with full metadata (artist, album, track number, artwork) and organized into your output folder.
`;
}
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 `