Add first-run setup wizard and fix download path reloading

7-step full-screen wizard: Welcome, Metadata Source, Download Source,
Paths & Media Server, Add Artists, First Download, Done. All settings
save to DB identically to the Settings page. Supports all 6 download
sources with inline config and test buttons. First download goes through
the full matched download pipeline with metadata context.

Fixes:
- Download clients (YouTube/HiFi/Tidal/Qobuz/Deezer) now reload
  download_path when settings change instead of caching from init
- watchlist_artists table migrations now include deezer_artist_id and
  discogs_artist_id in all 3 table rebuild locations (was being dropped)
- CREATE TABLE for watchlist_artists includes all provider ID columns
- Serverless download sources (YouTube/HiFi/Qobuz) show green status
  instead of red disconnected on sidebar and dashboard
- Suppress repeated slskd 401 errors — logs once then silences until
  connection recovers
This commit is contained in:
Broque Thomas 2026-04-11 22:45:16 -07:00
parent 71e4df65e3
commit 08de91685d
8 changed files with 1811 additions and 4 deletions

View file

@ -93,6 +93,17 @@ class DownloadOrchestrator:
self.deezer_dl.reconnect(deezer_arl)
self.deezer_dl._quality = config_manager.get('deezer_download.quality', 'flac')
# Reload download path for all clients that cache it
new_path = Path(config_manager.get('soulseek.download_path', './downloads'))
for client in [self.youtube, self.tidal, self.qobuz, self.hifi, self.deezer_dl]:
if client and hasattr(client, 'download_path') and client.download_path != new_path:
client.download_path = new_path
client.download_path.mkdir(parents=True, exist_ok=True)
# YouTube also caches path in yt-dlp opts
if hasattr(client, 'download_opts') and 'outtmpl' in client.download_opts:
client.download_opts['outtmpl'] = str(new_path / '%(title)s.%(ext)s')
logger.info(f"{type(client).__name__} download path updated to: {new_path}")
logger.info(f"Download Orchestrator settings reloaded - Mode: {self.mode}")
def _client(self, name):

View file

@ -345,6 +345,7 @@ class SoulseekClient:
if response.status in [200, 201, 204]: # Accept 200 OK, 201 Created, and 204 No Content
self._last_401_logged = False # Reset on success
try:
if response_text.strip(): # Only parse if there's content
return await response.json()
@ -362,11 +363,17 @@ class SoulseekClient:
# Enhanced error logging for better debugging
error_detail = response_text if response_text.strip() else "No error details provided"
# Reduce noise for expected 404s during search cleanup
# Reduce noise for expected 404s (e.g. status checks for YouTube downloads)
# and repeated 401s (slskd not running / bad credentials)
if response.status == 404:
logger.debug(f"API request returned 404 (Not Found) for {url}")
elif response.status == 401:
if not getattr(self, '_last_401_logged', False):
logger.warning(f"slskd authentication failed (401) — check API key. Suppressing further 401 errors.")
self._last_401_logged = True
logger.debug(f"API request 401 for {url}")
else:
self._last_401_logged = False
logger.error(f"API request failed: HTTP {response.status} ({response.reason}) - {error_detail}")
logger.debug(f"Failed request: {method} {url}")

View file

@ -201,6 +201,15 @@ class YouTubeClient:
self.download_opts['cookiesfrombrowser'] = (cookies_browser,)
elif 'cookiesfrombrowser' in self.download_opts:
del self.download_opts['cookiesfrombrowser']
# Reload download path
new_path = Path(config_manager.get('soulseek.download_path', './downloads'))
if new_path != self.download_path:
self.download_path = new_path
self.download_path.mkdir(parents=True, exist_ok=True)
self.download_opts['outtmpl'] = str(self.download_path / '%(title)s.%(ext)s')
logger.info(f"YouTube download path updated to: {self.download_path}")
logger.info(f"YouTube settings reloaded (delay={self._download_delay}s, cookies={'enabled' if cookies_browser else 'disabled'})")
async def check_connection(self) -> bool:

View file

@ -284,6 +284,9 @@ class MusicDatabase:
CREATE TABLE IF NOT EXISTS watchlist_artists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
spotify_artist_id TEXT UNIQUE,
itunes_artist_id TEXT,
deezer_artist_id TEXT,
discogs_artist_id TEXT,
artist_name TEXT NOT NULL,
date_added TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_scan_timestamp TIMESTAMP,
@ -1448,6 +1451,8 @@ class MusicDatabase:
include_acoustic INTEGER DEFAULT 0,
include_compilations INTEGER DEFAULT 0,
itunes_artist_id TEXT,
deezer_artist_id TEXT,
discogs_artist_id TEXT,
profile_id INTEGER DEFAULT 1,
UNIQUE(profile_id, spotify_artist_id),
UNIQUE(profile_id, itunes_artist_id)
@ -1471,7 +1476,9 @@ class MusicDatabase:
include_remixes INTEGER DEFAULT 0,
include_acoustic INTEGER DEFAULT 0,
include_compilations INTEGER DEFAULT 0,
itunes_artist_id TEXT
itunes_artist_id TEXT,
deezer_artist_id TEXT,
discogs_artist_id TEXT
)
""")
@ -1482,7 +1489,7 @@ class MusicDatabase:
'last_scan_timestamp', 'created_at', 'updated_at', 'image_url',
'include_albums', 'include_eps', 'include_singles', 'include_live',
'include_remixes', 'include_acoustic', 'include_compilations',
'itunes_artist_id', 'profile_id']
'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id', 'profile_id']
shared_cols = [c for c in new_cols if c in old_cols]
cols_str = ', '.join(shared_cols)
cursor.execute(f"INSERT INTO watchlist_artists_new ({cols_str}) SELECT {cols_str} FROM watchlist_artists")
@ -2211,6 +2218,8 @@ class MusicDatabase:
include_acoustic INTEGER DEFAULT 0,
include_compilations INTEGER DEFAULT 0,
itunes_artist_id TEXT,
deezer_artist_id TEXT,
discogs_artist_id TEXT,
profile_id INTEGER DEFAULT 1,
UNIQUE(profile_id, spotify_artist_id),
UNIQUE(profile_id, itunes_artist_id)
@ -2222,7 +2231,7 @@ class MusicDatabase:
'last_scan_timestamp', 'created_at', 'updated_at', 'image_url',
'include_albums', 'include_eps', 'include_singles', 'include_live',
'include_remixes', 'include_acoustic', 'include_compilations',
'itunes_artist_id', 'profile_id']
'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id', 'profile_id']
shared_cols = [c for c in new_cols if c in col_names]
cols_str = ', '.join(shared_cols)

View file

@ -4599,6 +4599,12 @@ def get_status():
soulseek_relevant = (download_mode == 'soulseek' or
(download_mode == 'hybrid' and 'soulseek' in hybrid_order))
# Serverless sources (YouTube, HiFi, Qobuz) are always available
serverless_sources = ('youtube', 'hifi', 'qobuz')
is_serverless = (download_mode in serverless_sources or
(download_mode == 'hybrid' and
hybrid_order and hybrid_order[0] in serverless_sources))
if soulseek_relevant and soulseek_client:
soulseek_start = time.time()
try:
@ -4606,6 +4612,9 @@ def get_status():
except Exception:
soulseek_status = False
soulseek_response_time = (time.time() - soulseek_start) * 1000
elif is_serverless:
soulseek_status = True
soulseek_response_time = 0
else:
soulseek_status = False
soulseek_response_time = 0
@ -6113,6 +6122,21 @@ def get_mirrored_playlists_list():
except Exception as e:
return jsonify({"playlists": [], "spotify_authenticated": False}), 200
@app.route('/api/setup/status', methods=['GET'])
def setup_status_endpoint():
"""Check if first-run setup has been completed."""
# Consider setup incomplete if no slskd URL and no download source configured
slskd_url = config_manager.get('soulseek.slskd_url', '')
download_mode = config_manager.get('download_source.mode', '')
transfer_path = config_manager.get('soulseek.transfer_path', '')
has_any_config = bool(slskd_url) or bool(download_mode) or bool(transfer_path)
return jsonify({
"setup_complete": has_any_config,
"has_download_source": bool(download_mode),
"has_slskd": bool(slskd_url),
"has_transfer_path": bool(transfer_path),
})
@app.route('/api/test-connection', methods=['POST'])
def test_connection_endpoint():
data = request.get_json()
@ -21252,6 +21276,20 @@ def get_version_info():
"title": "What's New in SoulSync",
"subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes",
"sections": [
{
"title": "First-Run Setup Wizard",
"description": "New full-screen guided setup for first-time users",
"features": [
"• 7-step wizard: Welcome, Metadata Source, Download Source, Paths & Media Server, Add Artists, First Download, Done",
"• All 6 download sources available: Soulseek, YouTube, HiFi, Tidal, Qobuz, Deezer — with inline config and test buttons",
"• Path fields default to /app/downloads and /app/Transfer with lock/unlock for Docker users",
"• Media server connection (Plex/Jellyfin/Navidrome) with inline test",
"• Add artists to watchlist with live search — shows watchlist status, add/remove in place",
"• First download step searches metadata, finds best match, and downloads through the full pipeline",
"• All settings save to DB identically to the Settings page — no difference in behavior",
],
"usage_note": "Open with ?setup=1 URL parameter or openSetupWizard() from browser console. First-run auto-detection coming soon."
},
{
"title": "Music Videos — Search & Download from YouTube",
"description": "New Music Videos tab in enhanced and global search for finding and downloading music videos",

View file

@ -8,9 +8,18 @@
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='mobile.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='setup-wizard.css') }}">
</head>
<body>
<!-- Setup Wizard Overlay -->
<div id="setup-wizard-overlay" class="setup-wizard-overlay" style="display: none;">
<div class="setup-wizard-container">
<div class="setup-stepper" id="setup-wizard-stepper"></div>
<div id="setup-wizard-content"></div>
</div>
</div>
<!-- Launch PIN Lock Screen -->
<div id="launch-pin-overlay" class="launch-pin-overlay" style="display: none;">
<div class="launch-pin-container" id="launch-pin-container">
@ -7367,6 +7376,7 @@
<script src="{{ url_for('static', filename='helper.js') }}"></script>
<script src="{{ url_for('static', filename='particles.js') }}"></script>
<script src="{{ url_for('static', filename='worker-orbs.js') }}"></script>
<script src="{{ url_for('static', filename='setup-wizard.js') }}"></script>
</body>
</html>

View file

@ -0,0 +1,732 @@
/* ============================================
SETUP WIZARD First-Run Full-Screen Overlay
============================================ */
.setup-wizard-overlay {
position: fixed;
inset: 0;
z-index: 100000;
background: linear-gradient(135deg, #0a0a1a 0%, #111827 50%, #0a0a1a 100%);
display: flex;
align-items: center;
justify-content: center;
animation: setupFadeIn 0.5s ease;
overflow-y: auto;
padding: 20px;
}
@keyframes setupFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.setup-wizard-container {
width: 100%;
max-width: 640px;
display: flex;
flex-direction: column;
align-items: center;
gap: 32px;
}
/* ---- Progress Stepper ---- */
.setup-stepper {
display: flex;
align-items: center;
gap: 0;
margin-bottom: 8px;
}
.setup-step-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
border: 2px solid rgba(255, 255, 255, 0.2);
transition: all 0.3s ease;
cursor: default;
}
.setup-step-dot.active {
background: rgb(var(--accent-rgb));
border-color: rgb(var(--accent-rgb));
box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.5);
}
.setup-step-dot.completed {
background: rgb(var(--accent-rgb));
border-color: rgb(var(--accent-rgb));
opacity: 0.6;
}
.setup-step-line {
width: 32px;
height: 2px;
background: rgba(255, 255, 255, 0.1);
transition: background 0.3s ease;
}
.setup-step-line.completed {
background: rgba(var(--accent-rgb), 0.5);
}
/* ---- Card Container ---- */
.setup-card {
width: 100%;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
padding: 40px;
backdrop-filter: blur(20px);
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.4);
animation: setupCardIn 0.35s ease;
}
@keyframes setupCardIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.setup-card h2 {
font-size: 1.6rem;
font-weight: 700;
color: #fff;
margin-bottom: 8px;
text-align: center;
}
.setup-card .setup-subtitle {
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.5);
text-align: center;
margin-bottom: 28px;
line-height: 1.5;
}
/* ---- Welcome Step ---- */
.setup-welcome-logo {
width: auto;
height: 80px;
max-width: 200px;
margin: 0 auto 20px;
display: block;
object-fit: contain;
}
.setup-welcome-tagline {
font-size: 1.05rem;
color: rgba(255, 255, 255, 0.6);
text-align: center;
margin-bottom: 32px;
}
.setup-feature-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 32px;
}
.setup-feature-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: rgba(255, 255, 255, 0.03);
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.setup-feature-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: rgba(var(--accent-rgb), 0.15);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: rgb(var(--accent-rgb));
}
.setup-feature-text {
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.8);
}
/* ---- Selection Cards (Metadata / Download Source) ---- */
.setup-option-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 24px;
}
.setup-option-card {
position: relative;
padding: 18px 16px;
background: rgba(255, 255, 255, 0.03);
border: 2px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
}
.setup-option-card:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.15);
}
.setup-option-card.selected {
border-color: rgb(var(--accent-rgb));
background: rgba(var(--accent-rgb), 0.08);
box-shadow: 0 0 20px rgba(var(--accent-rgb), 0.15);
}
.setup-option-card.selected::after {
content: '';
position: absolute;
top: 8px;
right: 8px;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgb(var(--accent-rgb));
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E");
background-size: 14px;
background-repeat: no-repeat;
background-position: center;
}
.setup-option-name {
font-size: 1rem;
font-weight: 600;
color: #fff;
margin-bottom: 4px;
}
.setup-option-badge {
display: inline-block;
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 6px;
background: rgba(var(--accent-rgb), 0.2);
color: rgb(var(--accent-light-rgb));
margin-bottom: 6px;
}
.setup-option-desc {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.45);
line-height: 1.4;
}
/* Inline config that appears when an option is selected */
.setup-inline-config {
display: none;
margin-top: 16px;
padding: 16px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
animation: setupCardIn 0.25s ease;
}
.setup-inline-config.visible {
display: block;
}
/* ---- Form Inputs ---- */
.setup-input-group {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.setup-input-group label {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.5);
font-weight: 500;
}
.setup-input {
width: 100%;
padding: 10px 14px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: #fff;
font-size: 0.9rem;
outline: none;
transition: border-color 0.2s ease;
}
.setup-input:focus {
border-color: rgba(var(--accent-rgb), 0.6);
}
.setup-input::placeholder {
color: rgba(255, 255, 255, 0.25);
}
.setup-test-btn {
padding: 8px 16px;
background: rgba(var(--accent-rgb), 0.15);
border: 1px solid rgba(var(--accent-rgb), 0.3);
border-radius: 8px;
color: rgb(var(--accent-light-rgb));
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
margin-top: 4px;
}
.setup-test-btn:hover {
background: rgba(var(--accent-rgb), 0.25);
}
.setup-test-btn.success {
border-color: #22c55e;
color: #22c55e;
background: rgba(34, 197, 94, 0.1);
}
.setup-test-btn.failed {
border-color: #ef4444;
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
/* ---- Media Server Cards ---- */
.setup-server-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 16px;
}
.setup-server-card {
padding: 14px 8px;
background: rgba(255, 255, 255, 0.03);
border: 2px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
cursor: pointer;
text-align: center;
transition: all 0.2s ease;
}
.setup-server-card:hover {
border-color: rgba(255, 255, 255, 0.15);
}
.setup-server-card.selected {
border-color: rgb(var(--accent-rgb));
background: rgba(var(--accent-rgb), 0.08);
}
.setup-server-name {
font-size: 0.85rem;
font-weight: 600;
color: #fff;
}
/* ---- Artist Search (Step 5) ---- */
.setup-search-wrapper {
position: relative;
margin-bottom: 16px;
}
.setup-search-input {
width: 100%;
padding: 12px 16px 12px 42px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: #fff;
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s ease;
}
.setup-search-input:focus {
border-color: rgba(var(--accent-rgb), 0.6);
}
.setup-search-input::placeholder {
color: rgba(255, 255, 255, 0.3);
}
.setup-search-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: rgba(255, 255, 255, 0.3);
}
.setup-artist-results {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 280px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(var(--accent-rgb), 0.3) transparent;
}
.setup-artist-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
}
.setup-artist-row:hover {
background: rgba(255, 255, 255, 0.07);
}
.setup-artist-row.added {
border-color: rgb(var(--accent-rgb));
background: rgba(var(--accent-rgb), 0.08);
}
.setup-artist-img {
width: 44px;
height: 44px;
border-radius: 50%;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
}
.setup-artist-info {
flex: 1;
min-width: 0;
}
.setup-artist-name {
font-size: 0.95rem;
font-weight: 600;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.setup-artist-genre {
font-size: 0.78rem;
color: rgba(255, 255, 255, 0.4);
}
/* Added artists chips */
.setup-added-artists {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.setup-added-chip {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(var(--accent-rgb), 0.12);
border: 1px solid rgba(var(--accent-rgb), 0.25);
border-radius: 20px;
font-size: 0.82rem;
color: rgb(var(--accent-light-rgb));
}
.setup-added-chip .remove {
cursor: pointer;
opacity: 0.6;
font-size: 1rem;
line-height: 1;
}
.setup-added-chip .remove:hover {
opacity: 1;
}
/* ---- Track Search (Step 6) ---- */
.setup-track-results {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 300px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(var(--accent-rgb), 0.3) transparent;
}
.setup-track-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
}
.setup-track-row:hover {
background: rgba(255, 255, 255, 0.07);
}
.setup-track-row.downloading {
pointer-events: none;
opacity: 0.7;
}
.setup-track-row.downloaded {
border-color: #22c55e;
background: rgba(34, 197, 94, 0.06);
}
.setup-track-art {
width: 40px;
height: 40px;
border-radius: 6px;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
}
.setup-track-info {
flex: 1;
min-width: 0;
}
.setup-track-title {
font-size: 0.9rem;
font-weight: 600;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.setup-track-artist {
font-size: 0.78rem;
color: rgba(255, 255, 255, 0.4);
}
.setup-track-status {
font-size: 0.78rem;
color: rgba(255, 255, 255, 0.4);
min-width: 80px;
text-align: right;
}
.setup-track-row.downloaded .setup-track-status {
color: #22c55e;
}
/* ---- Done Step ---- */
.setup-done-icon {
width: 72px;
height: 72px;
margin: 0 auto 20px;
border-radius: 50%;
background: rgba(var(--accent-rgb), 0.15);
display: flex;
align-items: center;
justify-content: center;
animation: setupPulse 2s ease infinite;
}
@keyframes setupPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.2); }
50% { box-shadow: 0 0 0 16px rgba(var(--accent-rgb), 0); }
}
.setup-summary {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 28px;
padding: 16px;
background: rgba(255, 255, 255, 0.03);
border-radius: 12px;
}
.setup-summary-row {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
}
.setup-summary-label {
color: rgba(255, 255, 255, 0.45);
}
.setup-summary-value {
color: #fff;
font-weight: 500;
}
/* ---- Buttons ---- */
.setup-btn-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 24px;
}
.setup-btn {
padding: 10px 24px;
border-radius: 10px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
border: none;
}
.setup-btn-primary {
background: rgb(var(--accent-rgb));
color: #fff;
box-shadow: 0 4px 16px rgba(var(--accent-rgb), 0.3);
}
.setup-btn-primary:hover {
filter: brightness(1.1);
box-shadow: 0 6px 20px rgba(var(--accent-rgb), 0.4);
}
.setup-btn-secondary {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.setup-btn-secondary:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.setup-btn-big {
display: block;
width: 100%;
padding: 14px;
font-size: 1rem;
}
.setup-skip-link {
display: block;
text-align: center;
margin-top: 16px;
font-size: 0.82rem;
color: rgba(255, 255, 255, 0.35);
cursor: pointer;
transition: color 0.2s ease;
background: none;
border: none;
}
.setup-skip-link:hover {
color: rgba(255, 255, 255, 0.6);
}
/* ---- Path Lock Row ---- */
.setup-path-row {
display: flex;
gap: 8px;
align-items: center;
}
.setup-path-row .setup-path-input {
flex: 1;
}
.setup-path-row .setup-path-input[readonly] {
opacity: 0.6;
cursor: not-allowed;
}
.setup-lock-btn {
padding: 8px 14px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.6);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.setup-lock-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.setup-lock-btn.locked {
border-color: rgba(var(--accent-rgb), 0.3);
color: rgba(var(--accent-rgb), 0.8);
}
/* ---- Artist check column ---- */
.setup-artist-check {
font-size: 0.78rem;
color: rgba(255, 255, 255, 0.35);
white-space: nowrap;
min-width: 70px;
text-align: right;
}
.setup-artist-row.added .setup-artist-check {
color: rgb(var(--accent-rgb));
font-weight: 600;
}
/* ---- Loading Spinner ---- */
.setup-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: rgb(var(--accent-rgb));
border-radius: 50%;
animation: setupSpin 0.6s linear infinite;
margin-right: 6px;
vertical-align: middle;
}
@keyframes setupSpin {
to { transform: rotate(360deg); }
}
/* ---- Responsive ---- */
@media (max-width: 600px) {
.setup-card {
padding: 24px 20px;
border-radius: 16px;
}
.setup-option-grid {
grid-template-columns: 1fr;
}
.setup-server-grid {
grid-template-columns: 1fr 1fr;
}
.setup-card h2 {
font-size: 1.3rem;
}
.setup-step-line {
width: 20px;
}
}

View file

@ -0,0 +1,991 @@
// ============================================
// 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';
}
// ---- 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 = `<div class="setup-step-dot ${dotClass}"></div>`;
if (i < WIZARD_STEPS.length - 1) html += `<div class="setup-step-line ${lineClass}"></div>`;
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 = `
<div class="setup-card">
<img src="/static/trans2.png" alt="SoulSync" class="setup-welcome-logo">
<h2>Welcome to SoulSync</h2>
<p class="setup-welcome-tagline">Intelligent Music Discovery & Automation</p>
<div class="setup-feature-list">
<div class="setup-feature-item">
<div class="setup-feature-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
</div>
<span class="setup-feature-text">Search and download music from multiple sources</span>
</div>
<div class="setup-feature-item">
<div class="setup-feature-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/></svg>
</div>
<span class="setup-feature-text">Sync playlists from Spotify, Tidal, Deezer and more</span>
</div>
<div class="setup-feature-item">
<div class="setup-feature-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88"/></svg>
</div>
<span class="setup-feature-text">Discover new music with watchlist monitoring and smart caching</span>
</div>
<div class="setup-feature-item">
<div class="setup-feature-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<span class="setup-feature-text">Organize and serve to Plex, Jellyfin, or Navidrome</span>
</div>
</div>
<button class="setup-btn setup-btn-primary setup-btn-big" onclick="wizardNext()">Get Started</button>
<button class="setup-skip-link" onclick="closeSetupWizard()">Skip Setup</button>
</div>
`;
}
// ---- 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 = `
<div class="setup-card">
<h2>Metadata Source</h2>
<p class="setup-subtitle">Where should SoulSync look up track info, album art, and metadata?</p>
<div class="setup-option-grid" style="grid-template-columns: 1fr 1fr 1fr;">
${sources.map(s => `
<div class="setup-option-card ${_wizardSettings.metadata_source === s.id ? 'selected' : ''}"
onclick="_wizardSelectMetadata('${s.id}')">
<div class="setup-option-name">${s.name}</div>
${s.badge ? `<div class="setup-option-badge">${s.badge}</div>` : ''}
<div class="setup-option-desc">${s.desc}</div>
</div>
`).join('')}
</div>
<div class="setup-btn-row">
<button class="setup-btn setup-btn-secondary" onclick="wizardBack()">Back</button>
<button class="setup-btn setup-btn-primary" onclick="wizardNext()">Next</button>
</div>
</div>
`;
}
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 = `
<div class="setup-inline-config visible">
<div class="setup-input-group">
<label>slskd URL</label>
<input class="setup-input" type="url" id="setup-slskd-url"
placeholder="http://localhost:5030"
value="${_escHtml(_wizardSettings.slskd_url)}"
oninput="_wizardSettings.slskd_url = this.value">
</div>
<div class="setup-input-group">
<label>slskd API Key</label>
<input class="setup-input" type="password" id="setup-slskd-key"
placeholder="Your slskd API key"
value="${_escHtml(_wizardSettings.slskd_api_key)}"
oninput="_wizardSettings.slskd_api_key = this.value">
</div>
<button class="setup-test-btn" id="setup-test-slskd" onclick="_wizardTestConnection('soulseek')">Test Connection</button>
</div>`;
} else if (sel === 'tidal') {
inlineConfig = `
<div class="setup-inline-config visible">
<div class="setup-input-group">
<label>Tidal Client ID</label>
<input class="setup-input" type="text" placeholder="Client ID"
value="${_escHtml(_wizardSettings.tidal_client_id)}"
oninput="_wizardSettings.tidal_client_id = this.value">
</div>
<div class="setup-input-group">
<label>Tidal Client Secret</label>
<input class="setup-input" type="password" placeholder="Client Secret"
value="${_escHtml(_wizardSettings.tidal_client_secret)}"
oninput="_wizardSettings.tidal_client_secret = this.value">
</div>
<button class="setup-test-btn" onclick="_wizardTestConnection('tidal')">Test Connection</button>
</div>`;
} else if (sel === 'deezer_dl') {
inlineConfig = `
<div class="setup-inline-config visible">
<div class="setup-input-group">
<label>Deezer ARL Token</label>
<input class="setup-input" type="password" placeholder="Your Deezer ARL token"
value="${_escHtml(_wizardSettings.deezer_arl)}"
oninput="_wizardSettings.deezer_arl = this.value">
</div>
</div>`;
}
el.innerHTML = `
<div class="setup-card">
<h2>Download Source</h2>
<p class="setup-subtitle">Choose your primary download source. You can enable Hybrid mode later in Settings to try multiple sources automatically.</p>
<div class="setup-option-grid" style="grid-template-columns: 1fr 1fr 1fr;">
${sources.map(s => `
<div class="setup-option-card ${sel === s.id ? 'selected' : ''}"
onclick="_wizardSelectDownload('${s.id}')">
<div class="setup-option-name">${s.name}</div>
<div class="setup-option-desc">${s.desc}</div>
</div>
`).join('')}
</div>
${inlineConfig}
<div class="setup-btn-row">
<button class="setup-btn setup-btn-secondary" onclick="wizardBack()">Back</button>
<button class="setup-btn setup-btn-primary" onclick="wizardNext()">Next</button>
</div>
</div>
`;
}
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 = '<span class="setup-spinner"></span>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 = `
<div class="setup-input-group">
<label>Plex URL</label>
<input class="setup-input" type="url" placeholder="http://localhost:32400"
value="${_escHtml(_wizardSettings.server_url)}" oninput="_wizardSettings.server_url = this.value">
</div>
<div class="setup-input-group">
<label>Plex Token</label>
<input class="setup-input" type="password" placeholder="Your Plex token"
value="${_escHtml(_wizardSettings.server_token)}" oninput="_wizardSettings.server_token = this.value">
</div>
<button class="setup-test-btn" onclick="_wizardTestConnection('plex')">Test Connection</button>
`;
} else if (server === 'jellyfin') {
serverFields = `
<div class="setup-input-group">
<label>Jellyfin URL</label>
<input class="setup-input" type="url" placeholder="http://localhost:8096"
value="${_escHtml(_wizardSettings.server_url)}" oninput="_wizardSettings.server_url = this.value">
</div>
<div class="setup-input-group">
<label>API Key</label>
<input class="setup-input" type="password" placeholder="Your Jellyfin API key"
value="${_escHtml(_wizardSettings.server_api_key)}" oninput="_wizardSettings.server_api_key = this.value">
</div>
<button class="setup-test-btn" onclick="_wizardTestConnection('jellyfin')">Test Connection</button>
`;
} else if (server === 'navidrome') {
serverFields = `
<div class="setup-input-group">
<label>Navidrome URL</label>
<input class="setup-input" type="url" placeholder="http://localhost:4533"
value="${_escHtml(_wizardSettings.server_url)}" oninput="_wizardSettings.server_url = this.value">
</div>
<div class="setup-input-group">
<label>Username</label>
<input class="setup-input" type="text" placeholder="admin"
value="${_escHtml(_wizardSettings.server_user)}" oninput="_wizardSettings.server_user = this.value">
</div>
<div class="setup-input-group">
<label>Password</label>
<input class="setup-input" type="password" placeholder="Password"
value="${_escHtml(_wizardSettings.server_pass)}" oninput="_wizardSettings.server_pass = this.value">
</div>
<button class="setup-test-btn" onclick="_wizardTestConnection('navidrome')">Test Connection</button>
`;
}
const dlLocked = _wizardPathLocks.download;
const trLocked = _wizardPathLocks.transfer;
el.innerHTML = `
<div class="setup-card">
<h2>Paths & Media Server</h2>
<p class="setup-subtitle">Where should downloaded music go?</p>
<div class="setup-input-group">
<label>Download Folder (where raw downloads land)</label>
<div class="setup-path-row">
<input class="setup-input setup-path-input" type="text" id="setup-download-path"
placeholder="/app/downloads"
value="${_escHtml(_wizardSettings.download_path)}"
${dlLocked ? 'readonly' : ''}
oninput="_wizardSettings.download_path = this.value">
<button class="setup-lock-btn ${dlLocked ? 'locked' : ''}" onclick="_wizardTogglePathLock('download')">
${dlLocked ? 'Unlock' : 'Lock'}
</button>
</div>
</div>
<div class="setup-input-group">
<label>Music Library / Transfer Folder (organized music)</label>
<div class="setup-path-row">
<input class="setup-input setup-path-input" type="text" id="setup-transfer-path"
placeholder="/app/Transfer"
value="${_escHtml(_wizardSettings.transfer_path)}"
${trLocked ? 'readonly' : ''}
oninput="_wizardSettings.transfer_path = this.value">
<button class="setup-lock-btn ${trLocked ? 'locked' : ''}" onclick="_wizardTogglePathLock('transfer')">
${trLocked ? 'Unlock' : 'Lock'}
</button>
</div>
</div>
<p class="setup-subtitle" style="margin-top: 20px; margin-bottom: 16px;">Connect a media server</p>
<div class="setup-server-grid">
${['plex', 'jellyfin', 'navidrome', 'none'].map(s => `
<div class="setup-server-card ${server === s ? 'selected' : ''}"
onclick="_wizardSelectServer('${s}')">
<div class="setup-server-name">${s === 'none' ? 'None' : s.charAt(0).toUpperCase() + s.slice(1)}</div>
</div>
`).join('')}
</div>
<div class="setup-inline-config ${showServerConfig ? 'visible' : ''}">
${serverFields}
</div>
<div class="setup-btn-row">
<button class="setup-btn setup-btn-secondary" onclick="wizardBack()">Back</button>
<button class="setup-btn setup-btn-primary" onclick="wizardNext()">Next</button>
</div>
</div>
`;
}
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) => `
<div class="setup-added-chip">
${_escHtml(a.name)}
<span class="remove" onclick="_wizardRemoveArtist(${i})">&times;</span>
</div>
`).join('');
el.innerHTML = `
<div class="setup-card">
<h2>Add Your First Artists</h2>
<p class="setup-subtitle">Search for artists to add to your watchlist. SoulSync will automatically download their new releases.</p>
<div class="setup-added-artists" id="setup-added-artists">${chips}</div>
<div class="setup-search-wrapper">
<span class="setup-search-icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
</span>
<input class="setup-search-input" type="text" id="setup-artist-search"
placeholder="Search for an artist..." autocomplete="off"
oninput="_wizardArtistSearch(this.value)">
</div>
<div class="setup-artist-results" id="setup-artist-results"></div>
<div class="setup-btn-row">
<button class="setup-btn setup-btn-secondary" onclick="wizardBack()">Back</button>
<div>
<button class="setup-btn setup-btn-secondary" onclick="wizardSkipStep()" style="margin-right: 8px;">Skip</button>
<button class="setup-btn setup-btn-primary" onclick="wizardNext()">Next</button>
</div>
</div>
</div>
`;
}
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 = '<div style="text-align:center;padding:12px;color:rgba(255,255,255,0.4);"><span class="setup-spinner"></span> Searching...</div>';
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 = '<div style="text-align:center;padding:12px;color:rgba(255,255,255,0.3);">No artists found</div>';
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 `
<div class="setup-artist-row ${isInWatchlist ? 'added' : ''}" data-artist-id="${_escHtml(id)}"
onclick="${isInWatchlist ? `_wizardRemoveArtistById('${_escHtml(id)}','${_escHtml(name)}')` : `_wizardAddArtistDirect('${_escHtml(id)}','${_escHtml(name)}','${_escHtml(img)}')`}">
${img ? `<img class="setup-artist-img" src="${_escHtml(img)}" alt="" onerror="this.style.display='none'">` : '<div class="setup-artist-img"></div>'}
<div class="setup-artist-info">
<div class="setup-artist-name">${_escHtml(name)}</div>
</div>
<div class="setup-artist-check">${isInWatchlist ? '&#10003; Watching' : '+ Add'}</div>
</div>
`;
}).join('');
} catch (e) {
console.error('Wizard artist search error:', e);
results.innerHTML = '<div style="text-align:center;padding:12px;color:#ef4444;">Search failed</div>';
}
}, 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) => `
<div class="setup-added-chip">
${_escHtml(a.name)}
<span class="remove" onclick="_wizardRemoveArtist(${i})">&times;</span>
</div>
`).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 = '&#10003; 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) => `
<div class="setup-added-chip">
${_escHtml(a.name)}
<span class="remove" onclick="_wizardRemoveArtist(${i})">&times;</span>
</div>
`).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 = `
<div class="setup-card">
<h2>Your First Download</h2>
<p class="setup-subtitle">Search for a track and download it to see SoulSync in action.</p>
<div class="setup-search-wrapper">
<span class="setup-search-icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
</span>
<input class="setup-search-input" type="text" id="setup-track-search"
placeholder="Search for a song..." autocomplete="off"
oninput="_wizardTrackSearch(this.value)">
</div>
<div class="setup-track-results" id="setup-track-results"></div>
<div class="setup-btn-row">
<button class="setup-btn setup-btn-secondary" onclick="wizardBack()">Back</button>
<div>
<button class="setup-btn setup-btn-secondary" onclick="wizardSkipStep()" style="margin-right: 8px;">Skip</button>
<button class="setup-btn setup-btn-primary" onclick="wizardNext()">Next</button>
</div>
</div>
</div>
`;
}
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 = '<div style="text-align:center;padding:12px;color:rgba(255,255,255,0.4);"><span class="setup-spinner"></span> Searching...</div>';
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 = '<div style="text-align:center;padding:12px;color:rgba(255,255,255,0.3);">No tracks found</div>';
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 `
<div class="setup-track-row" id="setup-track-${i}"
onclick="_wizardDownloadTrack(${i})">
${art ? `<img class="setup-track-art" src="${_escHtml(art)}" alt="" onerror="this.style.display='none'">` : '<div class="setup-track-art"></div>'}
<div class="setup-track-info">
<div class="setup-track-title">${_escHtml(title)}</div>
<div class="setup-track-artist">${_escHtml(artist)}${album ? ' &middot; ' + _escHtml(album) : ''}</div>
</div>
<div class="setup-track-status" id="setup-track-status-${i}">Click to download</div>
</div>
`;
}).join('');
} catch (e) {
console.error('Wizard track search error:', e);
results.innerHTML = '<div style="text-align:center;padding:12px;color:#ef4444;">Search failed</div>';
}
}, 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 = '<span class="setup-spinner"></span>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 = '<span class="setup-spinner"></span>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 = `
<div class="setup-card">
<div class="setup-done-icon">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--accent-rgb))" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
</div>
<h2>You're All Set!</h2>
<p class="setup-subtitle">SoulSync is configured and ready to go.</p>
<div class="setup-summary">
${summaryRows.map(r => `
<div class="setup-summary-row">
<span class="setup-summary-label">${r.label}</span>
<span class="setup-summary-value">${_escHtml(r.value)}</span>
</div>
`).join('')}
</div>
<button class="setup-btn setup-btn-primary setup-btn-big" onclick="_wizardFinish()">Start Using SoulSync</button>
</div>
`;
}
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);
}
localStorage.setItem('soulsync_setup_complete', 'true');
closeSetupWizard();
// Reload settings into the main UI
if (typeof loadSettings === 'function') loadSettings();
if (typeof showToast === 'function') showToast('Setup complete — welcome to SoulSync!', 'success');
}
// ---- Utility ----
function _escHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// ---- Dev Trigger ----
// Open wizard with: openSetupWizard() from console, or ?setup=1 URL param
(function _checkWizardAutoOpen() {
const params = new URLSearchParams(window.location.search);
if (params.get('setup') === '1') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(openSetupWizard, 300));
} else {
setTimeout(openSetupWizard, 300);
}
}
})();
// Expose globally
window.openSetupWizard = openSetupWizard;
window.closeSetupWizard = closeSetupWizard;