utilize quality profile over preferred.

pull/80/head
Broque Thomas 3 months ago
parent 14cfbd01f4
commit a3f98f29d3

@ -36,9 +36,6 @@
"path": "logs/app.log",
"level": "INFO"
},
"settings": {
"audio_quality": "flac"
},
"database": {
"path": "database/music_library.db",
"max_workers": 5

@ -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()

@ -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"""

@ -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')

@ -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:

@ -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)

@ -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')

@ -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 = []

@ -2269,18 +2269,7 @@
<!-- Download Settings -->
<div class="settings-group">
<h3>Download Settings</h3>
<div class="form-group">
<label>Preferred Quality:</label>
<select id="preferred-quality">
<option value="flac">FLAC</option>
<option value="mp3_320">320 kbps MP3</option>
<option value="mp3_256">256 kbps MP3</option>
<option value="mp3_192">192 kbps MP3</option>
<option value="any">Any</option>
</select>
</div>
<div class="form-group">
<label>Slskd Download Dir:</label>
<div class="path-input-group">

@ -1466,9 +1466,8 @@ async function loadSettingsData() {
// Populate Soulseek settings
document.getElementById('soulseek-url').value = settings.soulseek?.slskd_url || '';
document.getElementById('soulseek-api-key').value = settings.soulseek?.api_key || '';
// Populate Download settings (right column)
document.getElementById('preferred-quality').value = settings.settings?.audio_quality || 'flac';
document.getElementById('download-path').value = settings.soulseek?.download_path || './downloads';
document.getElementById('transfer-path').value = settings.soulseek?.transfer_path || './Transfer';
@ -1776,9 +1775,6 @@ async function saveSettings() {
download_path: document.getElementById('download-path').value,
transfer_path: document.getElementById('transfer-path').value
},
settings: {
audio_quality: document.getElementById('preferred-quality').value
},
database: {
max_workers: parseInt(document.getElementById('max-workers').value)
},

Loading…
Cancel
Save