diff --git a/core/download_orchestrator.py b/core/download_orchestrator.py index d1d630f3..ed306ea1 100644 --- a/core/download_orchestrator.py +++ b/core/download_orchestrator.py @@ -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): diff --git a/core/soulseek_client.py b/core/soulseek_client.py index 91d2c619..079d3600 100644 --- a/core/soulseek_client.py +++ b/core/soulseek_client.py @@ -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}") diff --git a/core/youtube_client.py b/core/youtube_client.py index 83f745e5..5d158230 100644 --- a/core/youtube_client.py +++ b/core/youtube_client.py @@ -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: diff --git a/database/music_database.py b/database/music_database.py index 4a567acd..95b54646 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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) diff --git a/web_server.py b/web_server.py index f3621afd..c3d67a2c 100644 --- a/web_server.py +++ b/web_server.py @@ -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", diff --git a/webui/index.html b/webui/index.html index da6512e4..d77e8e94 100644 --- a/webui/index.html +++ b/webui/index.html @@ -8,9 +8,18 @@ +
+ + +