diff --git a/.dockerignore b/.dockerignore index de93ff60..44215b24 100644 --- a/.dockerignore +++ b/.dockerignore @@ -61,6 +61,9 @@ main.py ui/ requirements.txt +# Dev-specific files +requirements-dev.txt + # OS generated files .DS_Store .DS_Store? @@ -68,4 +71,4 @@ requirements.txt .Spotlight-V100 .Trashes ehthumbs.db -Thumbs.db \ No newline at end of file +Thumbs.db diff --git a/Dockerfile b/Dockerfile index 97437292..5d3a2974 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,41 @@ # SoulSync WebUI Dockerfile # Multi-architecture support for AMD64 and ARM64 +# Stage 1: Builder ā install Python dependencies with compilation tools +FROM python:3.11-slim AS builder + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libc6-dev \ + libffi-dev \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +# Create virtualenv and install dependencies +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +COPY requirements-webui.txt . +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements-webui.txt + +# Stage 2: Runtime ā only runtime dependencies, no build tools FROM python:3.11-slim # Build-time commit SHA for update detection ARG COMMIT_SHA="" ENV SOULSYNC_COMMIT_SHA=${COMMIT_SHA} +# Copy pre-built virtualenv from builder +COPY --from=builder /opt/venv /opt/venv +ENV VIRTUAL_ENV=/opt/venv +ENV PATH="/opt/venv/bin:$PATH" + # Set working directory WORKDIR /app -# Install system dependencies -RUN apt-get update && apt-get install -y \ - gcc \ - libc6-dev \ - libffi-dev \ - libssl-dev \ +# Install runtime-only system dependencies (no gcc/build tools) +RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ gosu \ ffmpeg \ @@ -25,11 +45,6 @@ RUN apt-get update && apt-get install -y \ # Create non-root user for security RUN useradd --create-home --shell /bin/bash --uid 1000 soulsync -# Copy requirements and install Python dependencies -COPY requirements-webui.txt . -RUN pip install --no-cache-dir --upgrade pip && \ - pip install --no-cache-dir -r requirements-webui.txt - # Copy application code COPY . . @@ -75,4 +90,4 @@ ENV UMASK=022 # Set entrypoint and default command ENTRYPOINT ["/entrypoint.sh"] -CMD ["python", "web_server.py"] \ No newline at end of file +CMD ["python", "web_server.py"] diff --git a/README.md b/README.md index 14de99dc..27f21688 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,13 @@ python web_server.py # Open http://localhost:8008 ``` +For local development and tests: + +```bash +pip install -r requirements-dev.txt +pytest +``` + --- ## Setup Guide diff --git a/beatport_unified_scraper.py b/beatport_unified_scraper.py index f5bef9ff..eced8d24 100644 --- a/beatport_unified_scraper.py +++ b/beatport_unified_scraper.py @@ -127,7 +127,7 @@ class BeatportUnifiedScraper: response.raise_for_status() return BeautifulSoup(response.content, 'html.parser') except requests.RequestException as e: - print(f"ā Error fetching {url}: {e}") + print(f"Error fetching {url}: {e}") return None def clean_artist_track_data(self, raw_artist: str, raw_title: str) -> Dict[str, str]: @@ -171,12 +171,12 @@ class BeatportUnifiedScraper: def discover_genres_from_homepage(self) -> List[Dict]: """Dynamically discover all genres from Beatport homepage dropdown""" - print("š Discovering genres from Beatport homepage...") + print("Discovering genres from Beatport homepage...") try: soup = self.get_page(self.base_url) if not soup: - print("ā Could not fetch homepage") + print("Could not fetch homepage") return self.fallback_genres genres = [] @@ -185,14 +185,14 @@ class BeatportUnifiedScraper: genres_dropdown = soup.find('div', {'id': 'genres-dropdown-menu'}) if genres_dropdown: - print("ā Found genres-dropdown-menu") + print("Found genres-dropdown-menu") # Look for the two main div containers as described genre_containers = genres_dropdown.find_all('div', recursive=False) - print(f"š Found {len(genre_containers)} top-level containers in dropdown") + print(f"Found {len(genre_containers)} top-level containers in dropdown") for container_idx, container in enumerate(genre_containers): - print(f"š¦ Processing container {container_idx + 1}") + print(f"Processing container {container_idx + 1}") # Look specifically for .dropdown_menu classes dropdown_menus = container.find_all(class_='dropdown_menu') @@ -202,17 +202,17 @@ class BeatportUnifiedScraper: dropdown_menus = container.find_all(class_=re.compile(r'dropdown.*menu', re.I)) if not dropdown_menus: - print(f"ā ļø No .dropdown_menu found in container {container_idx + 1}") + print(f"No .dropdown_menu found in container {container_idx + 1}") continue for menu_idx, menu in enumerate(dropdown_menus): - print(f"š Processing dropdown_menu {menu_idx + 1} in container {container_idx + 1}") + print(f"Processing dropdown_menu {menu_idx + 1} in container {container_idx + 1}") # Look for
Click the link below to authenticate with Spotify:
{configured_uri}
@@ -6684,26 +6720,26 @@ def auth_spotify():
'''
else:
# Local access - simple message
- return f'Click the link below to authenticate:
After authentication, return to the app.
' + return f'Click the link below to authenticate:
After authentication, return to the app.
' else: - return "Could not initialize Spotify client. Check your credentials.
", 400 + return "Could not initialize Spotify client. Check your credentials.
", 400 except Exception as e: - print(f"š“ Error starting Spotify auth: {e}") - return f"{str(e)}
", 500 + print(f"Error starting Spotify auth: {e}") + return f"{str(e)}
", 500 @app.route('/auth/tidal') def auth_tidal(): """ Initiates Tidal OAuth authentication flow """ - print("ššš TIDAL AUTH ROUTE CALLED ššš") + print("TIDAL AUTH ROUTE CALLED ") try: # Create a fresh tidal client to get OAuth URL from core.tidal_client import TidalClient temp_tidal_client = TidalClient() if not temp_tidal_client.client_id: - return "Tidal client ID not configured. Check your credentials.
", 400 + return "Tidal client ID not configured. Check your credentials.
", 400 # Generate PKCE challenge and store globally temp_tidal_client._generate_pkce_challenge() @@ -6719,20 +6755,20 @@ def auth_tidal(): configured_redirect = config_manager.get('tidal.redirect_uri', '') if configured_redirect: temp_tidal_client.redirect_uri = configured_redirect - print(f"š Using configured Tidal redirect_uri: {configured_redirect}") + print(f"Using configured Tidal redirect_uri: {configured_redirect}") else: # Fallback: dynamically set based on request host (non-Docker local access) request_host = request.host.split(':')[0] if request_host not in ('127.0.0.1', 'localhost'): dynamic_redirect = f"http://{request_host}:8889/tidal/callback" temp_tidal_client.redirect_uri = dynamic_redirect - print(f"š Tidal redirect_uri set from request host: {dynamic_redirect}") + print(f"Tidal redirect_uri set from request host: {dynamic_redirect}") # Store PKCE + redirect_uri for callback to use the same values with tidal_oauth_lock: tidal_oauth_state["redirect_uri"] = temp_tidal_client.redirect_uri - print(f"š Stored PKCE - verifier: {temp_tidal_client.code_verifier[:20]}... challenge: {temp_tidal_client.code_challenge[:20]}...") + print(f"Stored PKCE - verifier: {temp_tidal_client.code_verifier[:20]}... challenge: {temp_tidal_client.code_challenge[:20]}...") # Store profile_id for per-profile auth profile_id = request.args.get('profile_id', '') @@ -6752,10 +6788,10 @@ def auth_tidal(): auth_url = f"{temp_tidal_client.auth_url}?" + urllib.parse.urlencode(params) - print(f"š Generated Tidal OAuth URL: {auth_url}") - print(f"š Redirect URI in URL: {params['redirect_uri']}") + print(f"Generated Tidal OAuth URL: {auth_url}") + print(f"Redirect URI in URL: {params['redirect_uri']}") - add_activity_item("š", "Tidal Auth Started", "Please complete OAuth in browser", "Now") + add_activity_item("", "Tidal Auth Started", "Please complete OAuth in browser", "Now") # Detect if accessing remotely (copied from Spotify auth logic) host = request.host.split(':')[0] @@ -6768,7 +6804,7 @@ def auth_tidal(): if is_remote or is_docker: # Show instructions for remote/docker access - page_title = "š Tidal Authentication (Remote/Docker)" + page_title = "Tidal Authentication (Remote/Docker)" step_1_text = "Click the link below to authenticate with Tidal" return f''' @@ -6809,7 +6845,7 @@ def auth_tidal(): function copyIP() {{ navigator.clipboard.writeText('{host}').then(() => {{ const btn = event.target; - btn.textContent = 'ā Copied!'; + btn.textContent = 'Copied!'; btn.classList.add('copied'); setTimeout(() => {{ btn.textContent = 'Copy IP'; @@ -6822,13 +6858,13 @@ def auth_tidal(): ''' else: - return f'Please visit this URL to authenticate:
After authentication, return to the app.
' + return f'Please visit this URL to authenticate:
After authentication, return to the app.
' except Exception as e: - print(f"š“ Error starting Tidal auth: {e}") + print(f"Error starting Tidal auth: {e}") import traceback - print(f"š“ Full traceback: {traceback.format_exc()}") - return f"{str(e)}
", 500 + print(f"Full traceback: {traceback.format_exc()}") + return f"{str(e)}
", 500 @app.route('/callback') @@ -6844,19 +6880,19 @@ def spotify_callback(): if not auth_code: error = request.args.get('error') if error: - print(f"š“ Spotify OAuth error on port 8008: Spotify returned error: {error}") - add_activity_item("ā", "Spotify Auth Failed", f"Spotify returned error: {error}", "Now") + print(f"Spotify OAuth error on port 8008: Spotify returned error: {error}") + add_activity_item("", "Spotify Auth Failed", f"Spotify returned error: {error}", "Now") return f"Spotify returned error: {error}
", 400 # No code AND no error ā check if query params were stripped if request.args: - print(f"š“ Spotify callback on port 8008 received unexpected params: {dict(request.args)}") + print(f"Spotify callback on port 8008 received unexpected params: {dict(request.args)}") else: # Completely empty ā likely a healthcheck or spurious request pass return '', 204 - print(f"šµ Spotify callback received on port 8008 with authorization code") + print(f"Spotify callback received on port 8008 with authorization code") # Check for per-profile state parameter state = request.args.get('state', '') @@ -6864,7 +6900,7 @@ def spotify_callback(): if state and state.startswith('profile_'): try: profile_id_from_state = int(state.replace('profile_', '')) - print(f"šµ Per-profile callback detected for profile {profile_id_from_state}") + print(f"Per-profile callback detected for profile {profile_id_from_state}") except ValueError: pass @@ -6892,7 +6928,7 @@ def spotify_callback(): # Invalidate cached profile client so it gets recreated with new tokens with _profile_spotify_lock: _profile_spotify_clients.pop(profile_id_from_state, None) - add_activity_item("ā ", "Spotify Auth Complete", f"Profile {profile_id_from_state} authenticated with Spotify", "Now") + add_activity_item("", "Spotify Auth Complete", f"Profile {profile_id_from_state} authenticated with Spotify", "Now") return "Your personal Spotify account is now connected. You can close this window.
" else: raise Exception("Failed to exchange authorization code for access token") @@ -6900,7 +6936,7 @@ def spotify_callback(): # Global callback (admin) config = config_manager.get_spotify_config() configured_uri = config.get('redirect_uri', "http://127.0.0.1:8888/callback") - print(f"šµ Using redirect_uri for token exchange: {configured_uri}") + print(f"Using redirect_uri for token exchange: {configured_uri}") auth_manager = SpotifyOAuth( client_id=config['client_id'], @@ -6928,15 +6964,15 @@ def spotify_callback(): if spotify_enrichment_worker and hasattr(spotify_enrichment_worker, 'client'): spotify_enrichment_worker.client.reload_config() spotify_enrichment_worker.client._invalidate_auth_cache() - add_activity_item("ā ", "Spotify Auth Complete", "Successfully authenticated with Spotify", "Now") + add_activity_item("", "Spotify Auth Complete", "Successfully authenticated with Spotify", "Now") return "You can close this window.
" else: raise Exception("Token exchange succeeded but authentication validation failed") else: raise Exception("Failed to exchange authorization code for access token") except Exception as e: - print(f"š“ Spotify OAuth callback error on port 8008: {e}") - add_activity_item("ā", "Spotify Auth Failed", f"Token processing failed: {str(e)}", "Now") + print(f"Spotify OAuth callback error on port 8008: {e}") + add_activity_item("", "Spotify Auth Failed", f"Token processing failed: {str(e)}", "Now") return f"{str(e)}
", 400 @@ -6960,7 +6996,7 @@ def spotify_disconnect(): } _status_cache_timestamps['spotify'] = time.time() fallback_label = 'Deezer' if fallback_src == 'deezer' else 'Discogs' if fallback_src == 'discogs' else 'iTunes' - add_activity_item("š", "Spotify Disconnected", f"Switched to {fallback_label} metadata source", "Now") + add_activity_item("", "Spotify Disconnected", f"Switched to {fallback_label} metadata source", "Now") return jsonify({'success': True, 'message': f'Spotify disconnected. Now using {fallback_label}.'}) except Exception as e: logger.error(f"Error disconnecting Spotify: {e}") @@ -7035,21 +7071,21 @@ def tidal_callback(): WHERE id = ? """, (enc_access, enc_refresh, profile_id_int)) conn.commit() - add_activity_item("ā ", "Tidal Auth Complete", f"Profile {profile_id_int} authenticated with Tidal", "Now") - return "Your personal Tidal account is now connected. You can close this window.
" + add_activity_item("", "Tidal Auth Complete", f"Profile {profile_id_int} authenticated with Tidal", "Now") + return "Your personal Tidal account is now connected. You can close this window.
" except Exception as profile_err: - print(f"ā ļø Per-profile Tidal auth failed, falling back to global: {profile_err}") + print(f"Per-profile Tidal auth failed, falling back to global: {profile_err}") # Global: Re-initialize the main global tidal_client instance with the new token tidal_client = TidalClient() if tidal_enrichment_worker: tidal_enrichment_worker.client = tidal_client - return "You can now close this window and return to the SoulSync application.
" + return "You can now close this window and return to the SoulSync application.
" else: - return "Could not exchange authorization code for a token. Please try again.
", 400 + return "Could not exchange authorization code for a token. Please try again.
", 400 except Exception as e: - print(f"š“ Error during Tidal token exchange: {e}") - return f"An unexpected error occurred during the authentication process: {e}
", 500 + print(f"Error during Tidal token exchange: {e}") + return f"An unexpected error occurred during the authentication process: {e}
", 500 # --- Deezer OAuth --- @@ -7071,7 +7107,7 @@ def auth_deezer(): host = request.host.split(':')[0] return f""" -Click the link below to authorize SoulSync with your Deezer account:
Your Deezer account is now connected. You can close this window.
""" @@ -7152,17 +7184,17 @@ def deezer_callback(): def get_beatport_hero_tracks(): """Get fresh tracks from Beatport hero slideshow for the rebuild slider""" try: - logger.info("šÆ Fetching Beatport hero tracks...") + logger.info("Fetching Beatport hero tracks...") # Check cache first cached_data = get_cached_beatport_data('homepage', 'hero_tracks') if cached_data: - logger.info("šÆ Returning cached hero tracks data") + logger.info("Returning cached hero tracks data") response = jsonify(cached_data) return add_cache_headers(response, 3600) # 1 hour # Cache miss - scrape fresh data - logger.info("š Cache miss - scraping fresh hero tracks data...") + logger.info("Cache miss - scraping fresh hero tracks data...") # Initialize scraper scraper = BeatportUnifiedScraper() @@ -7174,7 +7206,7 @@ def get_beatport_hero_tracks(): valid_tracks = [] seen_urls = set() - logger.info(f"š Processing {len(tracks)} raw tracks from scraper (SMART FILTERING)...") + logger.info(f"Processing {len(tracks)} raw tracks from scraper (SMART FILTERING)...") for i, track in enumerate(tracks): logger.info(f" Track {i+1}: {track.get('title', 'NO_TITLE')} - {track.get('artist', 'NO_ARTIST')}") @@ -7225,7 +7257,7 @@ def get_beatport_hero_tracks(): skip_reasons.append("Duplicate URL") if not is_valid: - logger.info(f" ā Track {i+1} filtered out: {', '.join(skip_reasons)}") + logger.info(f" Track {i+1} filtered out: {', '.join(skip_reasons)}") continue # Mark URL as seen for deduplication @@ -7268,9 +7300,9 @@ def get_beatport_hero_tracks(): break valid_tracks.append(track_data) - logger.info(f" ā Track {i+1} added: {title} - {artist}") + logger.info(f" Track {i+1} added: {title} - {artist}") - logger.info(f"ā Retrieved {len(valid_tracks)} valid unique Beatport tracks (SMART FILTERING)") + logger.info(f"Retrieved {len(valid_tracks)} valid unique Beatport tracks (SMART FILTERING)") # Prepare response data response_data = { @@ -7287,7 +7319,7 @@ def get_beatport_hero_tracks(): return add_cache_headers(response, 3600) # 1 hour except Exception as e: - logger.error(f"ā Error fetching Beatport tracks: {str(e)}") + logger.error(f"Error fetching Beatport tracks: {str(e)}") return jsonify({ 'success': False, 'error': str(e), @@ -7308,7 +7340,7 @@ def get_beatport_new_releases(): return add_cache_headers(response, 3600) # 1 hour # Cache miss - scrape fresh data - logger.info("š Cache miss - scraping fresh new releases data...") + logger.info("Cache miss - scraping fresh new releases data...") # Initialize scraper scraper = BeatportUnifiedScraper() @@ -7336,12 +7368,12 @@ def get_beatport_new_releases(): if releases_container: release_cards = releases_container.select('[class*="ReleaseCard-style__Wrapper"]') else: - logger.warning("ā ļø No New Releases GridSlider found, trying page-wide ReleaseCard search") + logger.warning("No New Releases GridSlider found, trying page-wide ReleaseCard search") release_cards = soup.select('[class*="ReleaseCard-style__Wrapper"]') releases = [] - logger.info(f"š Found {len(release_cards)} release cards") + logger.info(f"Found {len(release_cards)} release cards") for i, card in enumerate(release_cards[:100]): # Limit to 100 for 10 slides release_data = {} @@ -7400,7 +7432,7 @@ def get_beatport_new_releases(): releases.append(release_data) - logger.info(f"ā Successfully extracted {len(releases)} new releases") + logger.info(f"Successfully extracted {len(releases)} new releases") # Prepare response data response_data = { @@ -7418,7 +7450,7 @@ def get_beatport_new_releases(): return add_cache_headers(response, 3600) # 1 hour except Exception as e: - logger.error(f"ā Error fetching new releases: {str(e)}") + logger.error(f"Error fetching new releases: {str(e)}") return jsonify({ 'success': False, 'error': str(e), @@ -7430,17 +7462,17 @@ def get_beatport_new_releases(): def get_beatport_featured_charts(): """Get featured charts from Beatport for the charts slider grid using GridSlider approach""" try: - logger.info("š„ Fetching Beatport featured charts...") + logger.info("Fetching Beatport featured charts...") # Check cache first cached_data = get_cached_beatport_data('homepage', 'featured_charts') if cached_data: - logger.info("š„ Returning cached featured charts data") + logger.info("Returning cached featured charts data") response = jsonify(cached_data) return add_cache_headers(response, 3600) # 1 hour # Cache miss - scrape fresh data - logger.info("š Cache miss - scraping fresh featured charts data...") + logger.info("Cache miss - scraping fresh featured charts data...") # Initialize scraper scraper = BeatportUnifiedScraper() @@ -7454,21 +7486,21 @@ def get_beatport_featured_charts(): gridsliders = soup.select('[class*="GridSlider-style__Wrapper"]') featured_container = None - logger.info(f"š Checking {len(gridsliders)} GridSlider containers for featured charts...") + logger.info(f"Checking {len(gridsliders)} GridSlider containers for featured charts...") for container in gridsliders: h2 = container.select_one('h2') if h2: title = h2.get_text(strip=True).lower() - logger.info(f"š Found section: '{h2.get_text(strip=True)}'") + logger.info(f"Found section: '{h2.get_text(strip=True)}'") if 'featured' in title and 'chart' in title: featured_container = container - logger.info(f"š„ FOUND FEATURED CHARTS: '{h2.get_text(strip=True)}'") + logger.info(f"FOUND FEATURED CHARTS: '{h2.get_text(strip=True)}'") break if not featured_container: - logger.warning("ā No Featured Charts GridSlider container found") + logger.warning("No Featured Charts GridSlider container found") return jsonify({ 'success': False, 'error': 'Featured Charts section not found', @@ -7479,7 +7511,7 @@ def get_beatport_featured_charts(): charts = [] chart_links = featured_container.select('a[href*="/chart/"]') - logger.info(f"š Found {len(chart_links)} chart links in Featured Charts section") + logger.info(f"Found {len(chart_links)} chart links in Featured Charts section") for i, link in enumerate(chart_links[:100]): # Limit to 100 for 10 slides chart_data = {} @@ -7547,9 +7579,9 @@ def get_beatport_featured_charts(): # Only add if we have meaningful data if 'name' in chart_data and 'url' in chart_data: charts.append(chart_data) - logger.info(f"ā Chart {len(charts)}: {chart_data['name']} by {chart_data['creator']}") + logger.info(f"Chart {len(charts)}: {chart_data['name']} by {chart_data['creator']}") - logger.info(f"š Successfully extracted {len(charts)} featured charts") + logger.info(f"Successfully extracted {len(charts)} featured charts") # Prepare response data response_data = { @@ -7567,7 +7599,7 @@ def get_beatport_featured_charts(): return add_cache_headers(response, 3600) # 1 hour except Exception as e: - logger.error(f"ā Error fetching featured charts: {str(e)}") + logger.error(f"Error fetching featured charts: {str(e)}") return jsonify({ 'success': False, 'error': str(e), @@ -7579,17 +7611,17 @@ def get_beatport_featured_charts(): def get_beatport_dj_charts(): """Get DJ charts from Beatport for the DJ charts slider using Carousel approach""" try: - logger.info("š§ Fetching Beatport DJ charts...") + logger.info("Fetching Beatport DJ charts...") # Check cache first cached_data = get_cached_beatport_data('homepage', 'dj_charts') if cached_data: - logger.info("š§ Returning cached DJ charts data") + logger.info("Returning cached DJ charts data") response = jsonify(cached_data) return add_cache_headers(response, 3600) # 1 hour # Cache miss - scrape fresh data - logger.info("š Cache miss - scraping fresh DJ charts data...") + logger.info("Cache miss - scraping fresh DJ charts data...") # Initialize scraper scraper = BeatportUnifiedScraper() @@ -7603,21 +7635,21 @@ def get_beatport_dj_charts(): carousels = soup.select('[class*="Carousel-style__Wrapper"]') dj_container = None - logger.info(f"š Checking {len(carousels)} Carousel containers for DJ charts...") + logger.info(f"Checking {len(carousels)} Carousel containers for DJ charts...") # Based on test results, DJ charts are in the second carousel (index 1) with ~9 chart links for i, container in enumerate(carousels): chart_links = container.select('a[href*="/chart/"]') - logger.info(f"š Carousel {i+1}: {len(chart_links)} chart links") + logger.info(f"Carousel {i+1}: {len(chart_links)} chart links") # DJ charts container typically has 8-12 chart links (not 99+ like featured charts) if 5 <= len(chart_links) <= 15: dj_container = container - logger.info(f"š„ FOUND DJ CHARTS: Carousel {i+1} with {len(chart_links)} charts") + logger.info(f"FOUND DJ CHARTS: Carousel {i+1} with {len(chart_links)} charts") break if not dj_container: - logger.warning("ā No DJ Charts Carousel container found") + logger.warning("No DJ Charts Carousel container found") return jsonify({ 'success': False, 'error': 'DJ Charts section not found', @@ -7628,7 +7660,7 @@ def get_beatport_dj_charts(): charts = [] chart_links = dj_container.select('a[href*="/chart/"]') - logger.info(f"š Found {len(chart_links)} DJ chart links") + logger.info(f"Found {len(chart_links)} DJ chart links") for i, link in enumerate(chart_links): chart_data = {} @@ -7687,9 +7719,9 @@ def get_beatport_dj_charts(): # Only add if we have meaningful data if 'name' in chart_data and 'url' in chart_data: charts.append(chart_data) - logger.info(f"ā DJ Chart {len(charts)}: {chart_data['name']} by {chart_data['creator']}") + logger.info(f"DJ Chart {len(charts)}: {chart_data['name']} by {chart_data['creator']}") - logger.info(f"š Successfully extracted {len(charts)} DJ charts") + logger.info(f"Successfully extracted {len(charts)} DJ charts") # Prepare response data response_data = { @@ -7707,7 +7739,7 @@ def get_beatport_dj_charts(): return add_cache_headers(response, 3600) # 1 hour except Exception as e: - logger.error(f"ā Error fetching DJ charts: {str(e)}") + logger.error(f"Error fetching DJ charts: {str(e)}") return jsonify({ 'success': False, 'error': str(e), @@ -7754,7 +7786,7 @@ def search_music(): logger.info(f"Web UI Search initiated for: '{query}'") # Add activity for search start - add_activity_item("š", "Search Started", f"'{query}'", "Now") + add_activity_item("", "Search Started", f"'{query}'", "Now") try: tracks, albums = run_async(soulseek_client.search(query)) @@ -7778,7 +7810,7 @@ def search_music(): # Add activity for search completion total_results = len(all_results) - add_activity_item("ā ", "Search Complete", f"'{query}' - {total_results} results", "Now") + add_activity_item("", "Search Complete", f"'{query}' - {total_results} results", "Now") return jsonify({"results": all_results}) @@ -8024,15 +8056,13 @@ def enhanced_search_source(source_name): else: return jsonify({"artists": [], "albums": [], "tracks": [], "available": False}) elif source_name == 'itunes': - from core.itunes_client import iTunesClient - client = iTunesClient() + client = _get_itunes_client() elif source_name == 'deezer': client = _get_deezer_client() elif source_name == 'discogs': token = config_manager.get('discogs.token', '') if token: - from core.discogs_client import DiscogsClient - client = DiscogsClient(token=token) + client = _get_discogs_client(token) else: return jsonify({"artists": [], "albums": [], "tracks": [], "available": False}) elif source_name == 'hydrabase': @@ -8217,7 +8247,7 @@ def stream_enhanced_search_track(): if not track_name or not artist_name: return jsonify({"error": "Track name and artist name are required"}), 400 - logger.info(f"ā¶ļø Enhanced search stream request: '{track_name}' by '{artist_name}'") + logger.info(f"Enhanced search stream request: '{track_name}' by '{artist_name}'") try: # Create a temporary SpotifyTrack-like object for the matching engine @@ -8242,13 +8272,13 @@ def stream_enhanced_search_track(): _hybrid_first = _hybrid_order[0] if _hybrid_order else config_manager.get('download_source.hybrid_primary', 'hifi') if download_mode == 'soulseek' or (download_mode == 'hybrid' and _hybrid_first == 'soulseek'): effective_mode = 'youtube' # Soulseek is too slow for streaming preview - logger.info("ā¶ļø Stream source is 'active' but primary is Soulseek ā falling back to YouTube") + logger.info("Stream source is 'active' but primary is Soulseek ā falling back to YouTube") elif download_mode == 'hybrid': effective_mode = _hybrid_first else: effective_mode = download_mode - logger.info(f"ā¶ļø Stream source: {stream_source} ā effective: {effective_mode}") + logger.info(f"Stream source: {stream_source} ā effective: {effective_mode}") # Generate search queries based on effective stream mode search_queries = [] @@ -8265,7 +8295,7 @@ def stream_enhanced_search_track(): if cleaned_name and cleaned_name.lower() != track_name.lower(): search_queries.append(f"{artist_name} {cleaned_name}".strip()) - logger.info(f"š {effective_mode.title()} stream: Searching with artist + track name: {search_queries}") + logger.info(f"{effective_mode.title()} stream: Searching with artist + track name: {search_queries}") else: # Soulseek mode: Track name only to avoid keyword filtering if track_name.strip(): @@ -8277,7 +8307,7 @@ def stream_enhanced_search_track(): if cleaned_name and cleaned_name.lower() != track_name.lower(): search_queries.append(cleaned_name.strip()) - logger.info(f"š Soulseek mode: Searching by track name only (will match with artist): {search_queries}") + logger.info(f"Soulseek mode: Searching by track name only (will match with artist): {search_queries}") # Remove duplicates while preserving order unique_queries = [] @@ -8303,7 +8333,7 @@ def stream_enhanced_search_track(): # Try queries sequentially until we find a good match for query_index, query in enumerate(search_queries): - logger.info(f"š Query {query_index + 1}/{len(search_queries)}: '{query}'") + logger.info(f"Query {query_index + 1}/{len(search_queries)}: '{query}'") try: # Search using the stream source client (not the download source) @@ -8313,7 +8343,7 @@ def stream_enhanced_search_track(): tracks_result, _ = run_async(soulseek_client.search(query, timeout=15)) if tracks_result: - logger.info(f"ā Found {len(tracks_result)} results for query: '{query}'") + logger.info(f"Found {len(tracks_result)} results for query: '{query}'") # Use matching engine to find best match _max_q = config_manager.get('soulseek.max_peer_queue', 0) or 0 @@ -8337,30 +8367,30 @@ def stream_enhanced_search_track(): "result_type": "track" } - logger.info(f"ā Returning best match from query '{query}': {best_result.filename} ({best_result.quality})") + logger.info(f"Returning best match from query '{query}': {best_result.filename} ({best_result.quality})") return jsonify({ "success": True, "result": result_dict }) else: - logger.info(f"āļø No suitable matches for query '{query}', trying next query...") + logger.info(f"No suitable matches for query '{query}', trying next query...") else: - logger.info(f"āļø No results for query '{query}', trying next query...") + logger.info(f"No results for query '{query}', trying next query...") except Exception as search_error: - logger.warning(f"ā ļø Error searching with query '{query}': {search_error}") + logger.warning(f"Error searching with query '{query}': {search_error}") continue # If we get here, none of the queries found a suitable match - logger.warning(f"ā No suitable matches found after trying {len(search_queries)} queries") + logger.warning(f"No suitable matches found after trying {len(search_queries)} queries") return jsonify({ "success": False, "error": "No suitable track found after trying multiple search strategies" }), 404 except Exception as e: - logger.error(f"ā Error streaming enhanced search track: {e}", exc_info=True) + logger.error(f"Error streaming enhanced search track: {e}", exc_info=True) return jsonify({"error": str(e)}), 500 # ============================================================================= @@ -8436,16 +8466,16 @@ def download_music_video(): if best and best_score >= 0.5: artist_name = best.artists[0] if best.artists else raw_channel track_title = best.name - print(f"š¬ [Music Video] Matched to: {artist_name} - {track_title} (confidence: {best_score:.2f})") + print(f"[Music Video] Matched to: {artist_name} - {track_title} (confidence: {best_score:.2f})") else: # Parse artist from video title: "Artist - Title" pattern if ' - ' in raw_title: parts = raw_title.split(' - ', 1) artist_name = parts[0].strip() track_title = _re.sub(r'\s*[\(\[].*?[\)\]]', '', parts[1]).strip() - print(f"š¬ [Music Video] No metadata match, using parsed: {artist_name} - {track_title}") + print(f"[Music Video] No metadata match, using parsed: {artist_name} - {track_title}") except Exception as e: - print(f"ā ļø [Music Video] Metadata lookup failed: {e}") + print(f"[Music Video] Metadata lookup failed: {e}") if ' - ' in raw_title: parts = raw_title.split(' - ', 1) artist_name = parts[0].strip() @@ -8477,17 +8507,17 @@ def download_music_video(): _music_video_downloads[video_id]['status'] = 'completed' _music_video_downloads[video_id]['progress'] = 100 _music_video_downloads[video_id]['path'] = final_path - print(f"ā [Music Video] Downloaded: {artist_name} - {track_title} ā {final_path}") - add_activity_item("š¬", "Music Video Downloaded", f"{artist_name} - {track_title}", "Now") + print(f"[Music Video] Downloaded: {artist_name} - {track_title} ā {final_path}") + add_activity_item("", "Music Video Downloaded", f"{artist_name} - {track_title}", "Now") else: _music_video_downloads[video_id]['status'] = 'error' _music_video_downloads[video_id]['error'] = 'Download failed ā file not found' - print(f"ā [Music Video] Download failed for: {artist_name} - {track_title}") + print(f"[Music Video] Download failed for: {artist_name} - {track_title}") except Exception as e: _music_video_downloads[video_id]['status'] = 'error' _music_video_downloads[video_id]['error'] = str(e) - print(f"ā [Music Video] Error: {e}") + print(f"[Music Video] Error: {e}") # Run in background thread import threading @@ -8560,8 +8590,8 @@ def start_download(): # Add activity for album download start album_name = data.get('album_name', 'Unknown Album') - logger.info(f"š„ Starting simple album download: '{album_name}' with {started_downloads}/{len(tracks)} tracks") - add_activity_item("š„", "Album Download Started", f"'{album_name}' - {started_downloads} tracks", "Now") + logger.info(f"Starting simple album download: '{album_name}' with {started_downloads}/{len(tracks)} tracks") + add_activity_item("", "Album Download Started", f"'{album_name}' - {started_downloads} tracks", "Now") return jsonify({ "success": True, @@ -8574,13 +8604,13 @@ def start_download(): filename = data.get('filename') file_size = data.get('size', 0) - logger.info(f"š„ Download request - Username: {username}, Filename: {filename[:50]}...") + logger.info(f"Download request - Username: {username}, Filename: {filename[:50]}...") if not username or not filename: return jsonify({"error": "Missing username or filename."}), 400 download_id = run_async(soulseek_client.download(username, filename, file_size)) - logger.info(f"š„ Download ID returned: {download_id}") + logger.info(f"Download ID returned: {download_id}") if download_id: # Register download for post-processing (simple transfer to /Transfer) @@ -8606,8 +8636,8 @@ def start_download(): # Extract track name from filename for activity track_name = filename.split('/')[-1] if '/' in filename else filename.split('\\')[-1] if '\\' in filename else filename - logger.info(f"š„ Starting simple track download: '{track_name}'") - add_activity_item("š„", "Track Download Started", f"'{track_name}'", "Now") + logger.info(f"Starting simple track download: '{track_name}'") + add_activity_item("", "Track Download Started", f"'{track_name}'", "Now") return jsonify({"success": True, "message": "Download started"}) else: logger.error(f"Failed to start download for: {filename}") @@ -8680,11 +8710,11 @@ def _find_completed_file_robust(download_dir, api_filename, transfer_dir=None): file_path = os.path.join(root, file) # Fast path: if path aligns with expected directory structure, return now if api_dir_parts and _path_matches_api_dirs(file_path): - print(f"ā Found path-confirmed match in {location_name}: {file_path}") + print(f"Found path-confirmed match in {location_name}: {file_path}") return file_path, 1.0 if not api_dir_parts: # No directory info to disambiguate ā return first match (original behavior) - print(f"ā Found exact match in {location_name}: {file_path}") + print(f"Found exact match in {location_name}: {file_path}") return file_path, 1.0 exact_matches.append(file_path) continue @@ -8696,10 +8726,10 @@ def _find_completed_file_robust(download_dir, api_filename, transfer_dir=None): if stripped_stem != file_stem and stripped_stem + file_ext_part == target_basename: file_path = os.path.join(root, file) if api_dir_parts and _path_matches_api_dirs(file_path): - print(f"ā Found path-confirmed dedup match in {location_name}: {file_path}") + print(f"Found path-confirmed dedup match in {location_name}: {file_path}") return file_path, 1.0 if not api_dir_parts: - print(f"ā Found dedup-suffix match in {location_name}: {file_path}") + print(f"Found dedup-suffix match in {location_name}: {file_path}") return file_path, 1.0 exact_matches.append(file_path) continue @@ -8715,7 +8745,7 @@ def _find_completed_file_robust(download_dir, api_filename, transfer_dir=None): # Return best exact match (disambiguated by path), or fall back to fuzzy if exact_matches: if len(exact_matches) == 1: - print(f"ā Found exact match in {location_name}: {exact_matches[0]}") + print(f"Found exact match in {location_name}: {exact_matches[0]}") return exact_matches[0], 1.0 # Multiple files share the basename ā pick the one whose path best # matches the expected directory structure from the Soulseek remote path @@ -8727,7 +8757,7 @@ def _find_completed_file_robust(download_dir, api_filename, transfer_dir=None): if score > best_score: best_score = score best = m - print(f"ā ļø Found {len(exact_matches)} files named '{target_basename}' in {location_name}, picked best path match: {best}") + print(f"Found {len(exact_matches)} files named '{target_basename}' in {location_name}, picked best path match: {best}") return best, 1.0 return best_fuzzy_path, highest_fuzzy_similarity @@ -8749,7 +8779,7 @@ def _find_completed_file_robust(download_dir, api_filename, transfer_dir=None): if downloads_similarity > 0.85: location = 'downloads' if downloads_similarity < 1.0: - print(f"ā Found fuzzy match in downloads ({downloads_similarity:.2f}): {best_downloads_path}") + print(f"Found fuzzy match in downloads ({downloads_similarity:.2f}): {best_downloads_path}") return (best_downloads_path, location) # If not found in downloads and transfer_dir is provided, search there @@ -8760,7 +8790,7 @@ def _find_completed_file_robust(download_dir, api_filename, transfer_dir=None): if transfer_similarity > 0.85: location = 'transfer' if transfer_similarity < 1.0: - print(f"ā Found fuzzy match in transfer ({transfer_similarity:.2f}): {best_transfer_path}") + print(f"Found fuzzy match in transfer ({transfer_similarity:.2f}): {best_transfer_path}") return (best_transfer_path, location) # Don't spam logs - file not found is common for completed/processed downloads @@ -8825,7 +8855,7 @@ def get_download_status(): with matched_context_lock: has_active_context = context_key in matched_downloads_context if has_active_context: - print(f"š Orphaned key {context_key} has active context ā retry re-used same source, treating as active") + print(f"Orphaned key {context_key} has active context ā retry re-used same source, treating as active") _orphaned_download_keys.discard(context_key) # Fall through to normal processing below else: @@ -8836,10 +8866,10 @@ def get_download_status(): if found_path: try: os.remove(found_path) - print(f"š§¹ Deleted orphaned download: {os.path.basename(found_path)}") + print(f"Deleted orphaned download: {os.path.basename(found_path)}") orphan_cleaned = True except Exception as e: - print(f"ā ļø Failed to delete orphaned file (will retry next poll): {e}") + print(f"Failed to delete orphaned file (will retry next poll): {e}") else: # File not on disk (already gone or never written) ā nothing to clean orphan_cleaned = True @@ -8863,10 +8893,10 @@ def get_download_status(): available_keys = list(matched_downloads_context.keys())[:5] if not context else None if context: - print(f"ā [Context Lookup] Found context for key: {context_key}") + print(f"[Context Lookup] Found context for key: {context_key}") elif context_key not in _stale_transfer_keys: # Only log once per stale key to avoid spamming every poll cycle - print(f"ā ļø [Context Lookup] No context found for key: {context_key}") + print(f"[Context Lookup] No context found for key: {context_key}") print(f" Available keys: {available_keys}...") _stale_transfer_keys.add(context_key) @@ -8880,10 +8910,10 @@ def get_download_status(): # Prevent two contexts from claiming the same physical file _norm_path = os.path.normpath(found_path) if _norm_path in _files_claimed_this_cycle: - print(f"ā ļø File already claimed by another context this cycle: {os.path.basename(found_path)} ā deferring to next poll") + print(f"File already claimed by another context this cycle: {os.path.basename(found_path)} ā deferring to next poll") else: _files_claimed_this_cycle.add(_norm_path) - print(f"šÆ Found completed matched file on disk: {found_path}") + print(f"Found completed matched file on disk: {found_path}") completed_matched_downloads.append((context_key, context, found_path)) # Don't add to _processed_download_ids yet - wait until thread starts successfully @@ -8892,7 +8922,7 @@ def get_download_status(): if context_key in _download_retry_attempts: retry_count = _download_retry_attempts[context_key]['count'] elapsed = time.time() - _download_retry_attempts[context_key]['first_attempt'] - print(f"ā File found after {retry_count} retry attempt(s) ({elapsed:.1f}s): {os.path.basename(filename_from_api)}") + print(f"File found after {retry_count} retry attempt(s) ({elapsed:.1f}s): {os.path.basename(filename_from_api)}") del _download_retry_attempts[context_key] else: # File not found yet - implement retry logic instead of immediate give-up @@ -8904,7 +8934,7 @@ def get_download_status(): 'count': 1, 'first_attempt': time.time() } - print(f"ā³ File not found yet: '{os.path.basename(filename_from_api)}' - Will retry (attempt 1/{_download_retry_max})") + print(f"File not found yet: '{os.path.basename(filename_from_api)}' - Will retry (attempt 1/{_download_retry_max})") else: # Increment retry count _download_retry_attempts[context_key]['count'] += 1 @@ -8913,19 +8943,19 @@ def get_download_status(): if retry_count >= _download_retry_max: # Max retries reached, give up - print(f"ā CRITICAL: Could not find '{os.path.basename(filename_from_api)}' after {retry_count} attempts over {elapsed:.1f}s. Giving up.") + print(f"CRITICAL: Could not find '{os.path.basename(filename_from_api)}' after {retry_count} attempts over {elapsed:.1f}s. Giving up.") _processed_download_ids.add(context_key) # Clean up retry tracking del _download_retry_attempts[context_key] else: - print(f"ā³ File not found yet: '{os.path.basename(filename_from_api)}' - Will retry (attempt {retry_count}/{_download_retry_max}, elapsed: {elapsed:.1f}s)") + print(f"File not found yet: '{os.path.basename(filename_from_api)}' - Will retry (attempt {retry_count}/{_download_retry_max}, elapsed: {elapsed:.1f}s)") # If we found completed matched downloads, start processing them in background threads if completed_matched_downloads: def process_completed_downloads(): for context_key, context, found_path in completed_matched_downloads: try: - print(f"š Starting post-processing thread for: {context_key}") + print(f"Starting post-processing thread for: {context_key}") # Use verification wrapper if context has task tracking IDs, # otherwise call directly (race guard flag still gets set on context) _pp_task_id = context.get('task_id') @@ -8942,16 +8972,16 @@ def get_download_status(): # Only mark as processed AFTER thread starts successfully _processed_download_ids.add(context_key) - print(f"ā Marked as processed: {context_key}") + print(f"Marked as processed: {context_key}") # DON'T remove context immediately - verification worker needs it # Context will be cleaned up by verification worker after both processors complete - print(f"š¾ Keeping context for verification worker: {context_key}") + print(f"Keeping context for verification worker: {context_key}") except Exception as e: - print(f"ā Error starting post-processing thread for {context_key}: {e}") + print(f"Error starting post-processing thread for {context_key}: {e}") # Don't add to processed set if thread failed to start - print(f"ā ļø Will retry {context_key} on next check") + print(f"Will retry {context_key} on next check") # Start a single thread to manage the launching of all processing threads processing_thread = threading.Thread(target=process_completed_downloads) @@ -8997,14 +9027,14 @@ def get_download_status(): # Prevent two contexts from claiming the same physical file _st_norm = os.path.normpath(found_path) if _st_norm in _files_claimed_this_cycle: - print(f"ā ļø [{source_label}] File already claimed this cycle: {os.path.basename(found_path)} ā deferring") + print(f"[{source_label}] File already claimed this cycle: {os.path.basename(found_path)} ā deferring") continue _files_claimed_this_cycle.add(_st_norm) - print(f"šÆ [{source_label}] Found completed matched file on disk: {found_path}") + print(f"[{source_label}] Found completed matched file on disk: {found_path}") # Start post-processing thread def process_streaming_download(_ctx_key=context_key, _ctx=context, _path=found_path, _label=source_label): try: - print(f"š [{_label}] Starting post-processing thread for: {_ctx_key}") + print(f"[{_label}] Starting post-processing thread for: {_ctx_key}") # Use verification wrapper if context has task tracking IDs _st_task_id = _ctx.get('task_id') _st_batch_id = _ctx.get('batch_id') @@ -9018,9 +9048,9 @@ def get_download_status(): thread.daemon = True thread.start() _processed_download_ids.add(_ctx_key) - print(f"ā [{_label}] Marked as processed: {_ctx_key}") + print(f"[{_label}] Marked as processed: {_ctx_key}") except Exception as e: - print(f"ā [{_label}] Error starting post-processing thread for {_ctx_key}: {e}") + print(f"[{_label}] Error starting post-processing thread for {_ctx_key}: {e}") processing_thread = threading.Thread(target=process_streaming_download) processing_thread.daemon = True @@ -9031,7 +9061,7 @@ def get_download_status(): _processed_download_ids.add(context_key) except Exception as streaming_error: import traceback - print(f"ā ļø Could not fetch YouTube/Tidal downloads for status: {streaming_error}") + print(f"Could not fetch YouTube/Tidal downloads for status: {streaming_error}") traceback.print_exc() return jsonify({"transfers": all_transfers}) @@ -9154,7 +9184,7 @@ def get_task_candidates(task_id): "candidate_count": len(serialized), }) except Exception as e: - print(f"ā [Candidates] Error fetching candidates for task {task_id}: {e}") + print(f"[Candidates] Error fetching candidates for task {task_id}: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/downloads/task/{str(e)}
'.encode()) else: error = query_params.get('error', ['Unknown error'])[0] - print(f"š“ Tidal OAuth error: {error}") - add_activity_item("ā", "Tidal Auth Failed", f"OAuth error: {error}", "Now") + print(f"Tidal OAuth error: {error}") + add_activity_item("", "Tidal Auth Failed", f"OAuth error: {error}", "Now") self.send_response(400) self.send_header('Content-type', 'text/html') self.end_headers() @@ -48940,13 +49098,13 @@ def start_oauth_callback_servers(): def run_tidal_server(): try: tidal_server = HTTPServer(('0.0.0.0', 8889), TidalCallbackHandler) - print("š¶ Started Tidal OAuth callback server on port 8889") - print(f"š¶ Tidal server listening on all interfaces, port 8889") + print("Started Tidal OAuth callback server on port 8889") + print(f"Tidal server listening on all interfaces, port 8889") tidal_server.serve_forever() except Exception as e: - print(f"š“ Failed to start Tidal callback server: {e}") + print(f"Failed to start Tidal callback server: {e}") import traceback - print(f"š“ Full error: {traceback.format_exc()}") + print(f"Full error: {traceback.format_exc()}") # Start both servers in background threads spotify_thread = threading.Thread(target=run_spotify_server, daemon=True) @@ -48955,7 +49113,7 @@ def start_oauth_callback_servers(): spotify_thread.start() tidal_thread.start() - print("ā OAuth callback servers started") + print("OAuth callback servers started") # =============================================== # Artist Detail Spotify Integration Functions @@ -48964,15 +49122,20 @@ def start_oauth_callback_servers(): def get_spotify_artist_discography(artist_name): """Get complete artist discography from Spotify using proper matching""" try: - from core.spotify_client import SpotifyClient from core.matching_engine import MusicMatchingEngine - print(f"šµ Searching Spotify for artist: {artist_name}") + print(f"Searching Spotify for artist: {artist_name}") - # Initialize clients - spotify_client = SpotifyClient() + # Reuse cached profile-aware Spotify client + spotify_client = get_spotify_client_for_profile() matching_engine = MusicMatchingEngine() + if not spotify_client: + return { + 'success': False, + 'error': 'Spotify client unavailable' + } + # Search for multiple potential matches (not just 1) artists = spotify_client.search_artists(artist_name, limit=5) @@ -48989,7 +49152,7 @@ def get_spotify_artist_discography(artist_name): # Step 1: Try exact case-insensitive match for spotify_artist in artists: if artist_name.lower().strip() == spotify_artist.name.lower().strip(): - print(f"šÆ Exact match found: '{spotify_artist.name}'") + print(f"Exact match found: '{spotify_artist.name}'") best_match = spotify_artist highest_score = 1.0 break @@ -49002,7 +49165,7 @@ def get_spotify_artist_discography(artist_name): spotify_artist_normalized = matching_engine.normalize_string(spotify_artist.name) score = matching_engine.similarity_score(db_artist_normalized, spotify_artist_normalized) - print(f"š Fuzzy match candidate: '{spotify_artist.name}' (score: {score:.3f})") + print(f"Fuzzy match candidate: '{spotify_artist.name}' (score: {score:.3f})") if score > highest_score: highest_score = score @@ -49018,7 +49181,7 @@ def get_spotify_artist_discography(artist_name): artist = best_match spotify_artist_id = artist.id - print(f"šµ Found Spotify artist: {artist.name} (ID: {spotify_artist_id}, confidence: {highest_score:.3f})") + print(f"Found Spotify artist: {artist.name} (ID: {spotify_artist_id}, confidence: {highest_score:.3f})") # Get all albums (albums, singles, and compilations) all_albums = spotify_client.get_artist_albums(spotify_artist_id, album_type='album,single,compilation', limit=50) @@ -49029,7 +49192,7 @@ def get_spotify_artist_discography(artist_name): 'error': f'No albums found for artist "{artist_name}"' } - print(f"š Found {len(all_albums)} releases on Spotify") + print(f"Found {len(all_albums)} releases on Spotify") # Categorize releases albums = [] @@ -49094,7 +49257,7 @@ def get_spotify_artist_discography(artist_name): eps = _dedup_releases(eps) singles = _dedup_releases(singles) - print(f"š Categorized Spotify releases - Albums: {len(albums)}, EPs: {len(eps)}, Singles: {len(singles)}") + print(f"Categorized Spotify releases - Albums: {len(albums)}, EPs: {len(eps)}, Singles: {len(singles)}") return { 'success': True, @@ -49107,7 +49270,7 @@ def get_spotify_artist_discography(artist_name): } except Exception as e: - print(f"ā Error getting Spotify discography for {artist_name}: {e}") + print(f"Error getting Spotify discography for {artist_name}: {e}") return { 'success': False, 'error': str(e) @@ -49116,7 +49279,7 @@ def get_spotify_artist_discography(artist_name): def merge_discography_data(owned_releases, spotify_discography, db=None, artist_name=None): """Build discography from Spotify data with 'checking' state - ownership is resolved via SSE stream""" try: - print("š Building discography cards (fast path - no DB matching)...") + print("Building discography cards (fast path - no DB matching)...") def build_category(spotify_category, category_name): """Build cards for a category with checking state""" @@ -49143,7 +49306,7 @@ def merge_discography_data(owned_releases, spotify_discography, db=None, artist_ eps = build_category(spotify_discography['eps'], 'EPs') singles = build_category(spotify_discography['singles'], 'Singles') - print(f"ā Built discography cards - Albums: {len(albums)}, EPs: {len(eps)}, Singles: {len(singles)}") + print(f"Built discography cards - Albums: {len(albums)}, EPs: {len(eps)}, Singles: {len(singles)}") return { 'success': True, @@ -49153,7 +49316,7 @@ def merge_discography_data(owned_releases, spotify_discography, db=None, artist_ } except Exception as e: - print(f"ā Error building discography: {e}") + print(f"Error building discography: {e}") import traceback traceback.print_exc() return { @@ -49183,11 +49346,11 @@ try: mb_worker.start() if config_manager.get('musicbrainz_enrichment_paused', False): mb_worker.pause() - print("ā MusicBrainz enrichment worker initialized (paused ā restored from config)") + print("MusicBrainz enrichment worker initialized (paused ā restored from config)") else: - print("ā MusicBrainz enrichment worker initialized and started") + print("MusicBrainz enrichment worker initialized and started") except Exception as e: - print(f"ā ļø MusicBrainz worker initialization failed: {e}") + print(f"MusicBrainz worker initialization failed: {e}") mb_worker = None # --- MusicBrainz API Endpoints --- @@ -49260,11 +49423,11 @@ try: audiodb_worker.start() if config_manager.get('audiodb_enrichment_paused', False): audiodb_worker.pause() - print("ā AudioDB enrichment worker initialized (paused ā restored from config)") + print("AudioDB enrichment worker initialized (paused ā restored from config)") else: - print("ā AudioDB enrichment worker initialized and started") + print("AudioDB enrichment worker initialized and started") except Exception as e: - print(f"ā ļø AudioDB worker initialization failed: {e}") + print(f"AudioDB worker initialization failed: {e}") audiodb_worker = None # --- AudioDB API Endpoints --- @@ -49333,11 +49496,11 @@ try: discogs_worker.start() if config_manager.get('discogs_enrichment_paused', False): discogs_worker.pause() - print("ā Discogs enrichment worker initialized (paused ā restored from config)") + print("Discogs enrichment worker initialized (paused ā restored from config)") else: - print("ā Discogs enrichment worker initialized and started") + print("Discogs enrichment worker initialized and started") except Exception as e: - print(f"ā ļø Discogs worker initialization failed: {e}") + print(f"Discogs worker initialization failed: {e}") discogs_worker = None # --- Discogs API Endpoints --- @@ -49395,11 +49558,11 @@ try: deezer_worker.start() if config_manager.get('deezer_enrichment_paused', False): deezer_worker.pause() - print("ā Deezer enrichment worker initialized (paused ā restored from config)") + print("Deezer enrichment worker initialized (paused ā restored from config)") else: - print("ā Deezer enrichment worker initialized and started") + print("Deezer enrichment worker initialized and started") except Exception as e: - print(f"ā ļø Deezer worker initialization failed: {e}") + print(f"Deezer worker initialization failed: {e}") deezer_worker = None # --- Deezer API Endpoints --- @@ -49473,11 +49636,11 @@ try: spotify_enrichment_worker.paused = True # Set BEFORE start() to prevent race condition spotify_enrichment_worker.start() if spotify_enrichment_worker.paused: - print("ā Spotify enrichment worker initialized (paused ā restored from config)") + print("Spotify enrichment worker initialized (paused ā restored from config)") else: - print("ā Spotify enrichment worker initialized and started") + print("Spotify enrichment worker initialized and started") except Exception as e: - print(f"ā ļø Spotify enrichment worker initialization failed: {e}") + print(f"Spotify enrichment worker initialization failed: {e}") spotify_enrichment_worker = None # --- API Rate Monitor Endpoints --- @@ -49572,11 +49735,11 @@ try: itunes_enrichment_worker.start() if config_manager.get('itunes_enrichment_paused', False): itunes_enrichment_worker.pause() - print("ā iTunes enrichment worker initialized (paused ā restored from config)") + print("iTunes enrichment worker initialized (paused ā restored from config)") else: - print("ā iTunes enrichment worker initialized and started") + print("iTunes enrichment worker initialized and started") except Exception as e: - print(f"ā ļø iTunes enrichment worker initialization failed: {e}") + print(f"iTunes enrichment worker initialization failed: {e}") itunes_enrichment_worker = None # --- iTunes API Endpoints --- @@ -49648,11 +49811,11 @@ try: lastfm_worker.start() if config_manager.get('lastfm_enrichment_paused', False): lastfm_worker.pause() - print("ā Last.fm enrichment worker initialized (paused ā restored from config)") + print("Last.fm enrichment worker initialized (paused ā restored from config)") else: - print("ā Last.fm enrichment worker initialized and started") + print("Last.fm enrichment worker initialized and started") except Exception as e: - print(f"ā ļø Last.fm worker initialization failed: {e}") + print(f"Last.fm worker initialization failed: {e}") lastfm_worker = None # --- Last.fm API Endpoints --- @@ -49791,11 +49954,11 @@ try: genius_worker.paused = True genius_worker.start() if genius_worker.paused: - print("ā Genius enrichment worker initialized (paused ā restored from config)") + print("Genius enrichment worker initialized (paused ā restored from config)") else: - print("ā Genius enrichment worker initialized and started") + print("Genius enrichment worker initialized and started") except Exception as e: - print(f"ā ļø Genius worker initialization failed: {e}") + print(f"Genius worker initialization failed: {e}") genius_worker = None # --- Genius API Endpoints --- @@ -49868,11 +50031,11 @@ try: tidal_enrichment_worker.start() if config_manager.get('tidal_enrichment_paused', False): tidal_enrichment_worker.pause() - print("ā Tidal enrichment worker initialized (paused ā restored from config)") + print("Tidal enrichment worker initialized (paused ā restored from config)") else: - print("ā Tidal enrichment worker initialized and started") + print("Tidal enrichment worker initialized and started") except Exception as e: - print(f"ā ļø Tidal worker initialization failed: {e}") + print(f"Tidal worker initialization failed: {e}") tidal_enrichment_worker = None # --- Tidal Enrichment API Endpoints --- @@ -49942,11 +50105,11 @@ try: qobuz_enrichment_worker.start() if config_manager.get('qobuz_enrichment_paused', False): qobuz_enrichment_worker.pause() - print("ā Qobuz enrichment worker initialized (paused ā restored from config)") + print("Qobuz enrichment worker initialized (paused ā restored from config)") else: - print("ā Qobuz enrichment worker initialized and started") + print("Qobuz enrichment worker initialized and started") except Exception as e: - print(f"ā ļø Qobuz worker initialization failed: {e}") + print(f"Qobuz worker initialization failed: {e}") qobuz_enrichment_worker = None # --- Qobuz Enrichment API Endpoints --- @@ -50020,13 +50183,13 @@ try: hydrabase_worker = HydrabaseWorker(get_ws_and_lock=_get_hydrabase_ws_and_lock) hydrabase_worker.start() hydrabase_client = HydrabaseClient(get_ws_and_lock=_get_hydrabase_ws_and_lock) - print("ā Hydrabase P2P mirror worker and metadata client initialized") + print("Hydrabase P2P mirror worker and metadata client initialized") # Update API blueprint references if hasattr(app, 'soulsync'): app.soulsync['hydrabase_client'] = hydrabase_client app.soulsync['hydrabase_worker'] = hydrabase_worker except Exception as e: - print(f"ā ļø Hydrabase initialization failed: {e}") + print(f"Hydrabase initialization failed: {e}") hydrabase_worker = None hydrabase_client = None @@ -50043,9 +50206,9 @@ try: _hydrabase_ws = _auto_ws # Don't auto-enable dev mode ā user must explicitly activate dev mode # Auto-connect just establishes the WebSocket for fallback/search tab use - print(f"ā Hydrabase auto-connected to {_hydra_cfg['url']}") + print(f"Hydrabase auto-connected to {_hydra_cfg['url']}") except Exception as e: - print(f"ā ļø Hydrabase auto-reconnect failed: {e}") + print(f"Hydrabase auto-reconnect failed: {e}") # --- Hydrabase Worker API Endpoints --- @@ -50117,9 +50280,9 @@ try: soulid_db = MusicDatabase() soulid_worker = SoulIDWorker(database=soulid_db) soulid_worker.start() - print("ā SoulID worker initialized and started") + print("SoulID worker initialized and started") except Exception as e: - print(f"ā ļø SoulID worker initialization failed: {e}") + print(f"SoulID worker initialization failed: {e}") soulid_worker = None @app.route('/api/soulid/status', methods=['GET']) @@ -50151,9 +50314,9 @@ try: navidrome_client=navidrome_client, ) listening_stats_worker.start() - print("ā Listening stats worker initialized and started") + print("Listening stats worker initialized and started") except Exception as e: - print(f"ā ļø Listening stats worker initialization failed: {e}") + print(f"Listening stats worker initialization failed: {e}") listening_stats_worker = None # --- Stats API Endpoints --- @@ -50444,13 +50607,13 @@ def listening_stats_sync(): import threading def _do_sync(): try: - print("š [Stats Sync] Starting manual poll...") + print("[Stats Sync] Starting manual poll...") listening_stats_worker._poll() listening_stats_worker.stats['polls_completed'] += 1 listening_stats_worker.stats['last_poll'] = time.strftime('%Y-%m-%d %H:%M:%S') - print("ā [Stats Sync] Manual poll completed") + print("[Stats Sync] Manual poll completed") except Exception as e: - print(f"ā [Stats Sync] Manual poll failed: {e}") + print(f"[Stats Sync] Manual poll failed: {e}") import traceback traceback.print_exc() logger.error(f"Manual stats sync failed: {e}") @@ -50540,9 +50703,9 @@ try: repair_worker._progress_lock_ref = repair_job_progress_lock repair_worker._progress_states_ref = repair_job_progress_states repair_worker.start() - print("ā Repair worker initialized and started") + print("Repair worker initialized and started") except Exception as e: - print(f"ā ļø Repair worker initialization failed: {e}") + print(f"Repair worker initialization failed: {e}") repair_worker = None # --- Repair Worker API Endpoints --- @@ -51388,7 +51551,7 @@ def import_album_process(): errors.append(err_msg) logger.error(f"Import processing error: {err_msg}") - add_activity_item("š„", "Album Imported", f"{album_name} by {artist_name} ({processed}/{len(matches)} tracks)", "Now") + add_activity_item("", "Album Imported", f"{album_name} by {artist_name} ({processed}/{len(matches)} tracks)", "Now") # Emit events through automation engine ā same chain as download batches # batch_complete ā auto-scan ā library_scan_completed ā auto-update DB @@ -51648,7 +51811,7 @@ def import_singles_process(): errors.append(err_msg) logger.error(f"Import single processing error: {err_msg}") - add_activity_item("š„", "Singles Imported", f"{processed}/{len(files)} tracks processed", "Now") + add_activity_item("", "Singles Imported", f"{processed}/{len(files)} tracks processed", "Now") # Emit events through automation engine ā same chain as download batches # batch_complete ā auto-scan ā library_scan_completed ā auto-update DB @@ -51841,12 +52004,23 @@ def _build_status_payload(): if cooldown_remaining > 0: spotify_data['post_ban_cooldown'] = cooldown_remaining + # Count active downloads for nav badge + active_dl_count = 0 + try: + with tasks_lock: + for t in download_tasks.values(): + if t.get('status') in ('downloading', 'searching', 'post_processing', 'queued', 'pending'): + active_dl_count += 1 + except Exception: + pass + return { 'spotify': spotify_data, 'media_server': _status_cache.get('media_server', {}), 'soulseek': soulseek_data, 'active_media_server': config_manager.get_active_media_server(), - 'enrichment': _get_enrichment_status() + 'enrichment': _get_enrichment_status(), + 'active_downloads': active_dl_count, } def _build_watchlist_count_payload(profile_id=1): @@ -51906,13 +52080,13 @@ def _hydrabase_reconnect_loop(): ) _hydrabase_ws = ws _consecutive_failures = 0 - print(f"š [Hydrabase] Auto-reconnected to {hydra_cfg['url']}") + print(f"[Hydrabase] Auto-reconnected to {hydra_cfg['url']}") except Exception as e: _consecutive_failures += 1 if _consecutive_failures <= 3: - print(f"ā ļø [Hydrabase] Reconnect attempt failed ({_consecutive_failures}): {e}") + print(f"[Hydrabase] Reconnect attempt failed ({_consecutive_failures}): {e}") elif _consecutive_failures == 4: - print(f"ā ļø [Hydrabase] Reconnect failing repeatedly ā suppressing further logs until success") + print(f"[Hydrabase] Reconnect failing repeatedly ā suppressing further logs until success") except Exception: pass # Don't crash the monitor loop @@ -52454,30 +52628,30 @@ if __name__ == '__main__': log_path = config_manager.get('logging.path', 'logs/app.log') logger = setup_logging(log_level, log_path) - print("š Starting SoulSync Web UI Server...") + print("Starting SoulSync Web UI Server...") print("Open your browser and navigate to http://127.0.0.1:8008") # Start OAuth callback servers - print("š§ Starting OAuth callback servers...") + print("Starting OAuth callback servers...") start_oauth_callback_servers() # Startup diagnostics: Check and recover stuck flags - print("š Running startup diagnostics...") + print("Running startup diagnostics...") stuck_flags_recovered = check_and_recover_stuck_flags() if stuck_flags_recovered: - print("ā ļø Recovered stuck flags from previous session") + print("Recovered stuck flags from previous session") else: - print("ā No stuck flags detected - system healthy") + print("No stuck flags detected - system healthy") # Start simple background monitor when server starts - print("š§ Starting simple background monitor...") + print("Starting simple background monitor...") start_simple_background_monitor() - print("ā Simple background monitor started (includes automatic search cleanup)") + print("Simple background monitor started (includes automatic search cleanup)") # Wishlist/watchlist timers are now managed by AutomationEngine system automations # Pre-build import suggestions cache in background - print("š§ Pre-building import suggestions cache...") + print("Pre-building import suggestions cache...") start_import_suggestions_cache() # Initialize app start time for uptime tracking @@ -52488,26 +52662,26 @@ if __name__ == '__main__': _register_automation_handlers() if automation_engine: try: - print("š§ Starting automation engine...") + print("Starting automation engine...") automation_engine.start() - print("ā Automation engine started") + print("Automation engine started") try: automation_engine.emit('app_started', {}) except Exception: pass except AttributeError as e: - print(f"ā ļø Automation engine failed to start: {e}") + print(f"Automation engine failed to start: {e}") print(" If using Docker, check that your volume mount is /app/data (not /app/database)") logger.error(f"Automation engine start error (possible stale Docker volume): {e}") except Exception as e: - print(f"ā ļø Automation engine failed to start: {e}") + print(f"Automation engine failed to start: {e}") logger.error(f"Automation engine start error: {e}") # Add startup activity - add_activity_item("š", "System Started", "SoulSync Web UI Server initialized", "Now") + add_activity_item("", "System Started", "SoulSync Web UI Server initialized", "Now") # Start WebSocket background emitters - print("š§ Starting WebSocket background emitters...") + print("Starting WebSocket background emitters...") # Phase 1: Global pollers socketio.start_background_task(_emit_service_status_loop) socketio.start_background_task(_emit_watchlist_count_loop) @@ -52533,6 +52707,6 @@ if __name__ == '__main__': socketio.start_background_task(_hydrabase_reconnect_loop) # API Rate Monitor ā 1s push for speedometer gauges socketio.start_background_task(_emit_rate_monitor_loop) - print("ā WebSocket emitters started (Phase 1-7: global/dashboard/enrichment/tools/sync/automations/repair + rate monitor)") + print("WebSocket emitters started (Phase 1-7: global/dashboard/enrichment/tools/sync/automations/repair + rate monitor)") socketio.run(app, host='0.0.0.0', port=8008, debug=False, allow_unsafe_werkzeug=True) diff --git a/webui/index.html b/webui/index.html index f6267fc4..0d2b677b 100644 --- a/webui/index.html +++ b/webui/index.html @@ -8,9 +8,18 @@ + + + + + +
+ Intelligent Music Discovery & Automation
+This wizard will walk you through the essentials. Everything can be changed later in Settings.
+ + +Where should SoulSync look up track info, album art, and metadata?
+Choose where SoulSync downloads music files from.
+Where should downloaded music go?
+Connect a media server
+Search for artists to add to your watchlist.
+Try searching for a track to see the full pipeline in action.
+SoulSync is configured and ready to go. Here's a quick overview of what's available.
+