diff --git a/config/config.example.json b/config/config.example.json index 8afd09b..eed0e19 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -36,9 +36,6 @@ "path": "logs/app.log", "level": "INFO" }, - "settings": { - "audio_quality": "flac" - }, "database": { "path": "database/music_library.db", "max_workers": 5 diff --git a/config/settings.py b/config/settings.py index f9dc8aa..a9f8d75 100644 --- a/config/settings.py +++ b/config/settings.py @@ -140,11 +140,7 @@ class ConfigManager: validation['jellyfin'] = bool(self.get('jellyfin.base_url')) and bool(self.get('jellyfin.api_key')) validation['navidrome'] = bool(self.get('navidrome.base_url')) and bool(self.get('navidrome.username')) and bool(self.get('navidrome.password')) validation['active_media_server'] = active_server - + return validation - - def get_quality_preference(self) -> str: - """Get the user's preferred audio quality setting""" - return self.get('settings.audio_quality', 'flac') config_manager = ConfigManager() \ No newline at end of file diff --git a/core/soulseek_client.py b/core/soulseek_client.py index fd275a5..0fde518 100644 --- a/core/soulseek_client.py +++ b/core/soulseek_client.py @@ -1246,25 +1246,25 @@ class SoulseekClient: logger.error(f"Error during search history buffer maintenance: {e}") return False - async def search_and_download_best(self, query: str, preferred_quality: str = 'flac') -> Optional[str]: + async def search_and_download_best(self, query: str) -> Optional[str]: results = await self.search(query) - + if not results: logger.warning(f"No results found for: {query}") return None - - # Use the new quality filtering - filtered_results = self.filter_results_by_quality_preference(results, preferred_quality) - + + # Use quality profile filtering + filtered_results = self.filter_results_by_quality_preference(results) + if not filtered_results: logger.warning(f"No suitable quality results found for: {query}") return None - + best_result = filtered_results[0] quality_info = f"{best_result.quality.upper()}" if best_result.bitrate: quality_info += f" {best_result.bitrate}kbps" - + logger.info(f"Downloading: {best_result.filename} ({quality_info}) from {best_result.username}") return await self.download(best_result.username, best_result.filename, best_result.size) @@ -1280,73 +1280,131 @@ class SoulseekClient: logger.debug(f"Connection check failed: {e}") return False - def filter_results_by_quality_preference(self, results: List[TrackResult], preferred_quality: str) -> List[TrackResult]: + def filter_results_by_quality_preference(self, results: List[TrackResult]) -> List[TrackResult]: """ - Filter and sort results by quality preference with smart fallback. - Prefers exact match, then higher quality, then lower quality. + Filter candidates based on user's quality profile with file size constraints. + Uses priority waterfall logic: tries highest priority quality first, falls back to lower priorities. + Returns candidates matching quality profile constraints, sorted by confidence and size. """ + from database.music_database import MusicDatabase + if not results: return [] - - # Normalize preference to match our quality strings - quality_map = { - 'flac': 'flac', - 'mp3_320': ('mp3', 320), - 'mp3_256': ('mp3', 256), - 'mp3_192': ('mp3', 192), - 'any': 'any' + + # Get quality profile from database + db = MusicDatabase() + profile = db.get_quality_profile() + + logger.debug(f"Quality Filter: Using profile preset '{profile.get('preset', 'custom')}', filtering {len(results)} candidates") + + # Categorize candidates by quality with file size constraints + quality_buckets = { + 'flac': [], + 'mp3_320': [], + 'mp3_256': [], + 'mp3_192': [], + 'other': [] } - - if preferred_quality not in quality_map: - return results # Return all if unknown preference - - if preferred_quality == 'any': - # Sort by quality score for "any" preference - return sorted(results, key=lambda x: x.quality_score, reverse=True) - - # Separate results by quality categories - exact_matches = [] - higher_quality = [] - lower_quality = [] - - if preferred_quality == 'flac': - for result in results: - if result.quality.lower() == 'flac': - exact_matches.append(result) - elif result.quality.lower() == 'mp3' and result.bitrate and result.bitrate >= 320: - higher_quality.append(result) # High-quality MP3 as fallback + + # Track all candidates that pass size checks (for fallback) + size_filtered_all = [] + + for candidate in results: + if not candidate.quality: + quality_buckets['other'].append(candidate) + continue + + track_format = candidate.quality.lower() + track_bitrate = candidate.bitrate or 0 + file_size_mb = candidate.size / (1024 * 1024) # Convert bytes to MB + + # Categorize and apply file size constraints + if track_format == 'flac': + quality_config = profile['qualities'].get('flac', {}) + min_mb = quality_config.get('min_mb', 0) + max_mb = quality_config.get('max_mb', 999) + + # Check if within size range + if min_mb <= file_size_mb <= max_mb: + # Add to bucket if enabled + if quality_config.get('enabled', False): + quality_buckets['flac'].append(candidate) + # Always track for fallback + size_filtered_all.append(candidate) else: - lower_quality.append(result) - else: - # MP3 preference with specific bitrate - pref_format, pref_bitrate = quality_map[preferred_quality] - - for result in results: - if result.quality.lower() == 'flac': - higher_quality.append(result) # FLAC is always higher quality - elif result.quality.lower() == pref_format: - if result.bitrate: - if result.bitrate == pref_bitrate: - exact_matches.append(result) - elif result.bitrate > pref_bitrate: - higher_quality.append(result) - else: - lower_quality.append(result) - else: - exact_matches.append(result) # Unknown bitrate, assume match + logger.debug(f"Quality Filter: FLAC file rejected - {file_size_mb:.1f}MB outside range {min_mb}-{max_mb}MB") + + elif track_format == 'mp3': + # Determine MP3 quality tier based on bitrate + if track_bitrate >= 320: + quality_key = 'mp3_320' + elif track_bitrate >= 256: + quality_key = 'mp3_256' + elif track_bitrate >= 192: + quality_key = 'mp3_192' else: - lower_quality.append(result) - - # Sort each category by quality score and upload speed - def sort_key(result): - return (result.quality_score, result.upload_speed) - - exact_matches.sort(key=sort_key, reverse=True) - higher_quality.sort(key=sort_key, reverse=True) - lower_quality.sort(key=sort_key, reverse=True) - - # Return in preference order: exact > higher > lower - return exact_matches + higher_quality + lower_quality + quality_buckets['other'].append(candidate) + continue + + quality_config = profile['qualities'].get(quality_key, {}) + min_mb = quality_config.get('min_mb', 0) + max_mb = quality_config.get('max_mb', 999) + + # Check if within size range + if min_mb <= file_size_mb <= max_mb: + # Add to bucket if enabled + if quality_config.get('enabled', False): + quality_buckets[quality_key].append(candidate) + # Always track for fallback + size_filtered_all.append(candidate) + else: + logger.debug(f"Quality Filter: {quality_key.upper()} file rejected - {file_size_mb:.1f}MB outside range {min_mb}-{max_mb}MB") + else: + quality_buckets['other'].append(candidate) + + # Sort each bucket by quality score and size + for bucket in quality_buckets.values(): + bucket.sort(key=lambda x: (x.quality_score, x.size), reverse=True) + + # Debug logging + for quality, bucket in quality_buckets.items(): + if bucket: + logger.debug(f"Quality Filter: Found {len(bucket)} '{quality}' candidates (after size filtering)") + + # Waterfall priority logic: try qualities in priority order + # Build priority list from enabled qualities + quality_priorities = [] + for quality_name, quality_config in profile['qualities'].items(): + if quality_config.get('enabled', False): + priority = quality_config.get('priority', 999) + quality_priorities.append((priority, quality_name)) + + # Sort by priority (lower number = higher priority) + quality_priorities.sort() + + # Try each quality in priority order + for priority, quality_name in quality_priorities: + candidates_for_quality = quality_buckets.get(quality_name, []) + if candidates_for_quality: + logger.info(f"Quality Filter: Returning {len(candidates_for_quality)} '{quality_name}' candidates (priority {priority})") + return candidates_for_quality + + # If no enabled qualities matched, check if fallback is enabled + if profile.get('fallback_enabled', True): + logger.warning(f"Quality Filter: No enabled qualities matched, falling back to size-filtered candidates") + # Return candidates that passed size checks (even if quality disabled) + # This respects file size constraints while allowing any quality + if size_filtered_all: + size_filtered_all.sort(key=lambda x: (x.quality_score, x.size), reverse=True) + logger.info(f"Quality Filter: Returning {len(size_filtered_all)} fallback candidates (size-filtered, any quality)") + return size_filtered_all + else: + # All candidates failed size checks - respect user's constraints and fail + logger.warning(f"Quality Filter: All candidates failed size checks, returning empty (respecting size constraints)") + return [] + else: + logger.warning(f"Quality Filter: No enabled qualities matched and fallback is disabled, returning empty") + return [] async def get_session_info(self) -> Optional[Dict[str, Any]]: """Get slskd session information including version""" diff --git a/ui/pages/artists.py b/ui/pages/artists.py index 3ab28c2..d658c78 100644 --- a/ui/pages/artists.py +++ b/ui/pages/artists.py @@ -3039,21 +3039,17 @@ class DownloadMissingAlbumTracksModal(QDialog): print(f"❌ Artist '{spotify_artist_name}' NOT found in path: '{slskd_full_path}'. Discarding candidate.") if verified_candidates: - # Apply quality preference filtering before returning - from config.settings import config_manager - quality_preference = config_manager.get_quality_preference() - - # Filter candidates by quality preference with smart fallback + # Apply quality profile filtering before returning if hasattr(self.parent_artists_page, 'soulseek_client'): quality_filtered = self.parent_artists_page.soulseek_client.filter_results_by_quality_preference( - verified_candidates, quality_preference + verified_candidates ) - + if quality_filtered: verified_candidates = quality_filtered - print(f"🎯 Applied quality filtering ({quality_preference}): {len(verified_candidates)} candidates remain") + print(f"🎯 Applied quality profile filtering: {len(verified_candidates)} candidates remain") else: - print(f"⚠️ Quality filtering ({quality_preference}) removed all candidates, keeping originals") + print(f"⚠️ Quality profile filtering removed all candidates, keeping originals") best_confidence = verified_candidates[0].confidence best_version = getattr(verified_candidates[0], 'version_type', 'unknown') diff --git a/ui/pages/dashboard.py b/ui/pages/dashboard.py index 95522fa..362dfcc 100644 --- a/ui/pages/dashboard.py +++ b/ui/pages/dashboard.py @@ -1323,22 +1323,18 @@ class SimpleWishlistDownloadWorker(QRunnable): try: # Update status: Starting search self.signals.status_updated.emit(self.download_index, "🔍 Searching...") - - # Get quality preference - from config.settings import config_manager - quality_preference = config_manager.get_quality_preference() - + # Use async method in sync context loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - + try: # Update status: Found candidates, analyzing self.signals.status_updated.emit(self.download_index, "🔎 Analyzing results...") - + # Use the enhanced search method that provides more feedback results = loop.run_until_complete( - self._search_with_progress(self.query, quality_preference) + self._search_with_progress(self.query) ) if results and len(results) > 0: @@ -1369,35 +1365,33 @@ class SimpleWishlistDownloadWorker(QRunnable): except Exception as e: self.signals.download_failed.emit(self.download_index, str(e)) - async def _search_with_progress(self, query, quality_preference): + async def _search_with_progress(self, query): """Search for tracks with progress updates""" try: # Emit search progress self.signals.status_updated.emit(self.download_index, "🌐 Searching network...") - + # Perform the search (this would ideally use the soulseek client's search methods) # For now, we'll use the existing search_and_download_best method # but in a real implementation, you'd want to separate search from download - + # This is a simplified version - in practice you'd want to: # 1. Search for candidates - # 2. Filter by quality + # 2. Filter by quality profile # 3. Return the results for manual download - + # For now, let's use a direct approach from core.soulseek_client import SoulseekClient if hasattr(self.soulseek_client, 'search_tracks'): results = await self.soulseek_client.search_tracks(query) - + if results: - # Filter by quality preference if needed - filtered_results = self.soulseek_client.filter_results_by_quality_preference( - results, quality_preference - ) + # Filter by quality profile + filtered_results = self.soulseek_client.filter_results_by_quality_preference(results) return filtered_results - + return [] - + except Exception as e: logger.error(f"Error in search with progress: {e}") return [] @@ -4149,10 +4143,6 @@ class AutoWishlistProcessorWorker(QRunnable): def run(self): """Run automatic wishlist processing""" try: - # Get quality preference - from config.settings import config_manager - quality_preference = config_manager.get_quality_preference() - # Get all wishlist tracks (no limit - process everything) wishlist_tracks = self.wishlist_service.get_wishlist_tracks_for_download() @@ -4188,9 +4178,9 @@ class AutoWishlistProcessorWorker(QRunnable): try: download_id = loop.run_until_complete( - self.soulseek_client.search_and_download_best(query, quality_preference) + self.soulseek_client.search_and_download_best(query) ) - + track_id = track_data.get('spotify_track_id') if download_id and track_id: diff --git a/ui/pages/settings.py b/ui/pages/settings.py index fbb0615..ecb1dc8 100644 --- a/ui/pages/settings.py +++ b/ui/pages/settings.py @@ -1258,23 +1258,7 @@ class SettingsPage(QWidget): if hasattr(self, 'log_path_display'): self.log_path_display.setText(logging_config.get('path', 'logs/app.log')) - - # Load quality preference - if hasattr(self, 'quality_combo'): - audio_quality = config_manager.get('settings.audio_quality', 'FLAC') - # Map config values to combo box text - quality_mapping = { - 'flac': 'FLAC', - 'mp3_320': '320 kbps MP3', - 'mp3_256': '256 kbps MP3', - 'mp3_192': '192 kbps MP3', - 'any': 'Any' - } - display_quality = quality_mapping.get(audio_quality.lower(), audio_quality) - index = self.quality_combo.findText(display_quality) - if index >= 0: - self.quality_combo.setCurrentIndex(index) - + # Load metadata enhancement settings metadata_config = config_manager.get('metadata_enhancement', {}) if hasattr(self, 'metadata_enabled_checkbox'): @@ -1329,21 +1313,7 @@ class SettingsPage(QWidget): if hasattr(self, 'max_workers_combo'): max_workers = int(self.max_workers_combo.currentText()) config_manager.set('database.max_workers', max_workers) - - # Save Quality preference - if hasattr(self, 'quality_combo'): - quality_text = self.quality_combo.currentText() - # Map combo box text to config values - config_mapping = { - 'FLAC': 'flac', - '320 kbps MP3': 'mp3_320', - '256 kbps MP3': 'mp3_256', - '192 kbps MP3': 'mp3_192', - 'Any': 'any' - } - config_value = config_mapping.get(quality_text, 'flac') - config_manager.set('settings.audio_quality', config_value) - + # Emit signals for path changes to update other pages immediately self.settings_changed.emit('soulseek.download_path', self.download_path_input.text()) self.settings_changed.emit('soulseek.transfer_path', self.transfer_path_input.text()) @@ -2721,22 +2691,7 @@ class SettingsPage(QWidget): download_layout = QVBoxLayout(download_group) download_layout.setContentsMargins(16, 20, 16, 16) download_layout.setSpacing(12) - - # Quality preference - quality_layout = QHBoxLayout() - quality_label = QLabel("Preferred Quality:") - quality_label.setStyleSheet(self.get_label_style(12)) - - self.quality_combo = QComboBox() - self.quality_combo.addItems(["FLAC", "320 kbps MP3", "256 kbps MP3", "192 kbps MP3", "Any"]) - self.quality_combo.setCurrentText("FLAC") - self.quality_combo.setStyleSheet(self.get_combo_style()) - self.quality_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.form_inputs['settings.audio_quality'] = self.quality_combo - - quality_layout.addWidget(quality_label) - quality_layout.addWidget(self.quality_combo) - + # Download path path_container = QVBoxLayout() path_label = QLabel("Slskd Download Dir:") @@ -2776,8 +2731,7 @@ class SettingsPage(QWidget): transfer_input_layout.addWidget(self.transfer_path_input) transfer_input_layout.addWidget(transfer_browse_btn) transfer_path_container.addLayout(transfer_input_layout) - - download_layout.addLayout(quality_layout) + download_layout.addLayout(path_container) download_layout.addLayout(transfer_path_container) diff --git a/ui/pages/sync.py b/ui/pages/sync.py index 487876c..03bd6d3 100644 --- a/ui/pages/sync.py +++ b/ui/pages/sync.py @@ -3068,40 +3068,9 @@ class SyncOptionsPanel(QFrame): border: 2px solid #1db954; } """) - - # Quality selection - quality_layout = QHBoxLayout() - quality_label = QLabel("Preferred Quality:") - quality_label.setStyleSheet("color: #b3b3b3; font-size: 11px;") - - self.quality_combo = QComboBox() - self.quality_combo.addItems(["FLAC", "320 kbps MP3", "256 kbps MP3", "Any"]) - self.quality_combo.setCurrentText("FLAC") - self.quality_combo.setStyleSheet(""" - QComboBox { - background: #404040; - border: 1px solid #606060; - border-radius: 4px; - padding: 5px; - color: #ffffff; - font-size: 11px; - } - QComboBox::drop-down { - border: none; - } - QComboBox::down-arrow { - image: none; - border: none; - } - """) - - quality_layout.addWidget(quality_label) - quality_layout.addWidget(self.quality_combo) - quality_layout.addStretch() - + layout.addWidget(title_label) layout.addWidget(self.download_missing) - layout.addLayout(quality_layout) class SyncPage(QWidget): # Signals for dashboard activity tracking @@ -8935,21 +8904,17 @@ class DownloadMissingTracksModal(QDialog): print(f"❌ Artist '{spotify_artist_name}' NOT found in path: '{slskd_full_path}'. Discarding candidate.") if verified_candidates: - # Apply quality preference filtering before returning - from config.settings import config_manager - quality_preference = config_manager.get_quality_preference() - - # Filter candidates by quality preference with smart fallback + # Apply quality profile filtering before returning if hasattr(self.parent_page, 'soulseek_client'): quality_filtered = self.parent_page.soulseek_client.filter_results_by_quality_preference( - verified_candidates, quality_preference + verified_candidates ) - + if quality_filtered: verified_candidates = quality_filtered - print(f"🎯 Applied quality filtering ({quality_preference}): {len(verified_candidates)} candidates remain") + print(f"🎯 Applied quality profile filtering: {len(verified_candidates)} candidates remain") else: - print(f"⚠️ Quality filtering ({quality_preference}) removed all candidates, keeping originals") + print(f"⚠️ Quality profile filtering removed all candidates, keeping originals") best_confidence = verified_candidates[0].confidence best_version = getattr(verified_candidates[0], 'version_type', 'unknown') diff --git a/web_server.py b/web_server.py index c4a53ce..39dc361 100644 --- a/web_server.py +++ b/web_server.py @@ -8728,129 +8728,6 @@ def stop_duplicate_cleaner(): # == DOWNLOAD MISSING TRACKS == # =============================== -def _filter_candidates_by_quality_preference(candidates): - """ - Filter candidates based on user's quality profile with file size constraints. - Uses priority waterfall logic: tries highest priority quality first, falls back to lower priorities. - Returns candidates matching quality profile constraints, sorted by confidence and size. - """ - from database.music_database import MusicDatabase - - # Get quality profile from database - db = MusicDatabase() - profile = db.get_quality_profile() - - print(f"🎵 [Quality Filter] Using profile preset: '{profile.get('preset', 'custom')}', filtering {len(candidates)} candidates") - - # Categorize candidates by quality with file size constraints - quality_buckets = { - 'flac': [], - 'mp3_320': [], - 'mp3_256': [], - 'mp3_192': [], - 'other': [] - } - - # Track all candidates that pass size checks (for fallback) - size_filtered_all = [] - - for candidate in candidates: - if not candidate.quality: - quality_buckets['other'].append(candidate) - continue - - track_format = candidate.quality.lower() - track_bitrate = candidate.bitrate or 0 - file_size_mb = candidate.size / (1024 * 1024) # Convert bytes to MB - - # Categorize and apply file size constraints - if track_format == 'flac': - quality_config = profile['qualities'].get('flac', {}) - min_mb = quality_config.get('min_mb', 0) - max_mb = quality_config.get('max_mb', 999) - - # Check if within size range - if min_mb <= file_size_mb <= max_mb: - # Add to bucket if enabled - if quality_config.get('enabled', False): - quality_buckets['flac'].append(candidate) - # Always track for fallback - size_filtered_all.append(candidate) - else: - print(f"🎵 [Quality Filter] FLAC file rejected: {file_size_mb:.1f}MB outside range {min_mb}-{max_mb}MB") - - elif track_format == 'mp3': - # Determine MP3 quality tier based on bitrate - if track_bitrate >= 320: - quality_key = 'mp3_320' - elif track_bitrate >= 256: - quality_key = 'mp3_256' - elif track_bitrate >= 192: - quality_key = 'mp3_192' - else: - quality_buckets['other'].append(candidate) - continue - - quality_config = profile['qualities'].get(quality_key, {}) - min_mb = quality_config.get('min_mb', 0) - max_mb = quality_config.get('max_mb', 999) - - # Check if within size range - if min_mb <= file_size_mb <= max_mb: - # Add to bucket if enabled - if quality_config.get('enabled', False): - quality_buckets[quality_key].append(candidate) - # Always track for fallback - size_filtered_all.append(candidate) - else: - print(f"🎵 [Quality Filter] {quality_key.upper()} file rejected: {file_size_mb:.1f}MB outside range {min_mb}-{max_mb}MB") - else: - quality_buckets['other'].append(candidate) - - # Sort each bucket by quality score and size - for bucket in quality_buckets.values(): - bucket.sort(key=lambda x: (x.quality_score, x.size), reverse=True) - - # Debug logging - for quality, bucket in quality_buckets.items(): - if bucket: - print(f"🎵 [Quality Filter] Found {len(bucket)} '{quality}' candidates (after size filtering)") - - # Waterfall priority logic: try qualities in priority order - # Build priority list from enabled qualities - quality_priorities = [] - for quality_name, quality_config in profile['qualities'].items(): - if quality_config.get('enabled', False): - priority = quality_config.get('priority', 999) - quality_priorities.append((priority, quality_name)) - - # Sort by priority (lower number = higher priority) - quality_priorities.sort() - - # Try each quality in priority order - for priority, quality_name in quality_priorities: - candidates_for_quality = quality_buckets.get(quality_name, []) - if candidates_for_quality: - print(f"🎯 [Quality Filter] Returning {len(candidates_for_quality)} '{quality_name}' candidates (priority {priority})") - return candidates_for_quality - - # If no enabled qualities matched, check if fallback is enabled - if profile.get('fallback_enabled', True): - print(f"⚠️ [Quality Filter] No enabled qualities matched, falling back to size-filtered candidates") - # Return candidates that passed size checks (even if quality disabled) - # This respects file size constraints while allowing any quality - if size_filtered_all: - size_filtered_all.sort(key=lambda x: (x.quality_score, x.size), reverse=True) - print(f"🎯 [Quality Filter] Returning {len(size_filtered_all)} fallback candidates (size-filtered, any quality)") - return size_filtered_all - else: - # All candidates failed size checks - respect user's constraints and fail - print(f"❌ [Quality Filter] All candidates failed size checks, returning empty (respecting size constraints)") - return [] - else: - print(f"❌ [Quality Filter] No enabled qualities matched and fallback is disabled, returning empty") - return [] - def get_valid_candidates(results, spotify_track, query): """ This function is a direct port from sync.py. It scores and filters @@ -8864,10 +8741,13 @@ def get_valid_candidates(results, spotify_track, query): if not initial_candidates: return [] - # Filter by user's quality preference before artist verification - quality_filtered_candidates = _filter_candidates_by_quality_preference(initial_candidates) + # Filter by user's quality profile before artist verification + # Use shared soulseek_client method for consistency + from core.soulseek_client import SoulseekClient + temp_client = SoulseekClient() + quality_filtered_candidates = temp_client.filter_results_by_quality_preference(initial_candidates) if not quality_filtered_candidates: - # If no candidates match preference, fall back to all candidates + # If no candidates match profile, fall back to all candidates quality_filtered_candidates = initial_candidates verified_candidates = [] diff --git a/webui/index.html b/webui/index.html index 62ad7ee..c932abb 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2269,18 +2269,7 @@