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:
parent
71e4df65e3
commit
08de91685d
8 changed files with 1811 additions and 4 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
732
webui/static/setup-wizard.css
Normal file
732
webui/static/setup-wizard.css
Normal 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;
|
||||
}
|
||||
}
|
||||
991
webui/static/setup-wizard.js
Normal file
991
webui/static/setup-wizard.js
Normal 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})">×</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 ? '✓ 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})">×</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 = '✓ 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})">×</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 ? ' · ' + _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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ---- 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;
|
||||
Loading…
Reference in a new issue