diff --git a/core/soulseek_client.py b/core/soulseek_client.py index 60a55783..7005cc8a 100644 --- a/core/soulseek_client.py +++ b/core/soulseek_client.py @@ -1453,11 +1453,28 @@ class SoulseekClient: logger.debug(f"Connection check failed: {e}") return False + @staticmethod + def _calculate_effective_kbps(size_bytes: int, duration_ms: Optional[int]) -> Optional[float]: + """Calculate effective bitrate in kbps from file size and duration.""" + if not duration_ms or duration_ms <= 0 or not size_bytes or size_bytes <= 0: + return None + duration_seconds = duration_ms / 1000.0 + return (size_bytes * 8) / duration_seconds / 1000.0 + + # Internal fallback size limits (MB) when duration is unavailable — generous to catch only extreme outliers + _FALLBACK_SIZE_LIMITS = { + 'flac': (1, 500), + 'mp3_320': (1, 50), + 'mp3_256': (1, 40), + 'mp3_192': (1, 30), + 'other': (0, 500), + } + def filter_results_by_quality_preference(self, results: List[TrackResult]) -> List[TrackResult]: """ - Filter candidates based on user's quality profile with file size constraints. + Filter candidates based on user's quality profile with bitrate density 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. + Returns candidates matching quality profile constraints, sorted by confidence and effective bitrate. """ from database.music_database import MusicDatabase @@ -1470,7 +1487,7 @@ class SoulseekClient: logger.debug(f"Quality Filter: Using profile preset '{profile.get('preset', 'custom')}', filtering {len(results)} candidates") - # Categorize candidates by quality with file size constraints + # Categorize candidates by quality with bitrate density constraints quality_buckets = { 'flac': [], 'mp3_320': [], @@ -1479,8 +1496,8 @@ class SoulseekClient: 'other': [] } - # Track all candidates that pass size checks (for fallback) - size_filtered_all = [] + # Track all candidates that pass checks (for fallback) + density_filtered_all = [] for candidate in results: if not candidate.quality: @@ -1489,26 +1506,11 @@ class SoulseekClient: 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 + # Determine quality key 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: - logger.debug(f"Quality Filter: FLAC file rejected - {file_size_mb:.1f}MB outside range {min_mb}-{max_mb}MB") - + quality_key = 'flac' elif track_format == 'mp3': - # Determine MP3 quality tier based on bitrate if track_bitrate >= 320: quality_key = 'mp3_320' elif track_bitrate >= 256: @@ -1518,31 +1520,44 @@ class SoulseekClient: else: quality_buckets['other'].append(candidate) continue + 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) + quality_config = profile['qualities'].get(quality_key, {}) + min_kbps = quality_config.get('min_kbps', 0) + max_kbps = quality_config.get('max_kbps', 99999) - # Check if within size range - if min_mb <= file_size_mb <= max_mb: - # Add to bucket if enabled + effective_kbps = self._calculate_effective_kbps(candidate.size, candidate.duration) + + if effective_kbps is not None: + # Primary: bitrate density check + if min_kbps <= effective_kbps <= max_kbps: if quality_config.get('enabled', False): quality_buckets[quality_key].append(candidate) - # Always track for fallback - size_filtered_all.append(candidate) + density_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") + logger.debug(f"Quality Filter: {quality_key} rejected - {effective_kbps:.0f} kbps outside {min_kbps}-{max_kbps} kbps range") else: - quality_buckets['other'].append(candidate) + # Fallback: duration unavailable, use generous raw-size sanity check + file_size_mb = candidate.size / (1024 * 1024) + size_min, size_max = self._FALLBACK_SIZE_LIMITS.get(quality_key, (0, 500)) + if size_min <= file_size_mb <= size_max: + if quality_config.get('enabled', False): + quality_buckets[quality_key].append(candidate) + density_filtered_all.append(candidate) + logger.debug(f"Quality Filter: {quality_key} accepted via size fallback ({file_size_mb:.1f} MB, no duration available)") + else: + logger.debug(f"Quality Filter: {quality_key} rejected via size fallback - {file_size_mb:.1f} MB outside {size_min}-{size_max} MB safety limits") - # Sort each bucket by quality score and size + # Sort each bucket by quality score and effective bitrate for bucket in quality_buckets.values(): - bucket.sort(key=lambda x: (x.quality_score, x.size), reverse=True) + bucket.sort(key=lambda x: (x.quality_score, self._calculate_effective_kbps(x.size, x.duration) or 0), 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)") + logger.debug(f"Quality Filter: Found {len(bucket)} '{quality}' candidates (after bitrate filtering)") # Waterfall priority logic: try qualities in priority order # Build priority list from enabled qualities @@ -1564,16 +1579,13 @@ class SoulseekClient: # 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 + logger.warning(f"Quality Filter: No enabled qualities matched, falling back to density-filtered candidates") + if density_filtered_all: + density_filtered_all.sort(key=lambda x: (x.quality_score, self._calculate_effective_kbps(x.size, x.duration) or 0), reverse=True) + logger.info(f"Quality Filter: Returning {len(density_filtered_all)} fallback candidates (bitrate-filtered, any quality)") + return density_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)") + logger.warning(f"Quality Filter: All candidates failed bitrate checks, returning empty (respecting constraints)") return [] else: logger.warning(f"Quality Filter: No enabled qualities matched and fallback is disabled, returning empty") diff --git a/database/music_database.py b/database/music_database.py index ad4c05cb..ced24eb9 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -2849,37 +2849,45 @@ class MusicDatabase: if profile_json: try: - return json.loads(profile_json) + profile = json.loads(profile_json) + # Migrate v1 profiles (min_mb/max_mb) to v2 (min_kbps/max_kbps) + if profile.get('version', 1) < 2: + logger.info("Migrating quality profile from v1 (file size) to v2 (bitrate density)") + return self._get_default_quality_profile() + return profile except json.JSONDecodeError: logger.error("Failed to parse quality profile JSON, returning default") - # Return smart defaults (balanced preset) + return self._get_default_quality_profile() + + def _get_default_quality_profile(self) -> dict: + """Return the default v2 quality profile (balanced preset)""" return { - "version": 1, + "version": 2, "preset": "balanced", "qualities": { "flac": { "enabled": True, - "min_mb": 0, - "max_mb": 150, + "min_kbps": 500, + "max_kbps": 10000, "priority": 1 }, "mp3_320": { "enabled": True, - "min_mb": 0, - "max_mb": 20, + "min_kbps": 280, + "max_kbps": 500, "priority": 2 }, "mp3_256": { "enabled": True, - "min_mb": 0, - "max_mb": 15, + "min_kbps": 200, + "max_kbps": 400, "priority": 3 }, "mp3_192": { "enabled": False, - "min_mb": 0, - "max_mb": 12, + "min_kbps": 150, + "max_kbps": 300, "priority": 4 } }, @@ -2903,93 +2911,93 @@ class MusicDatabase: """Get a predefined quality preset""" presets = { "audiophile": { - "version": 1, + "version": 2, "preset": "audiophile", "qualities": { "flac": { "enabled": True, - "min_mb": 0, - "max_mb": 200, + "min_kbps": 500, + "max_kbps": 10000, "priority": 1 }, "mp3_320": { "enabled": False, - "min_mb": 0, - "max_mb": 20, + "min_kbps": 280, + "max_kbps": 500, "priority": 2 }, "mp3_256": { "enabled": False, - "min_mb": 0, - "max_mb": 15, + "min_kbps": 200, + "max_kbps": 400, "priority": 3 }, "mp3_192": { "enabled": False, - "min_mb": 0, - "max_mb": 12, + "min_kbps": 150, + "max_kbps": 300, "priority": 4 } }, "fallback_enabled": False }, "balanced": { - "version": 1, + "version": 2, "preset": "balanced", "qualities": { "flac": { "enabled": True, - "min_mb": 0, - "max_mb": 150, + "min_kbps": 500, + "max_kbps": 10000, "priority": 1 }, "mp3_320": { "enabled": True, - "min_mb": 0, - "max_mb": 20, + "min_kbps": 280, + "max_kbps": 500, "priority": 2 }, "mp3_256": { "enabled": True, - "min_mb": 0, - "max_mb": 15, + "min_kbps": 200, + "max_kbps": 400, "priority": 3 }, "mp3_192": { "enabled": False, - "min_mb": 0, - "max_mb": 12, + "min_kbps": 150, + "max_kbps": 300, "priority": 4 } }, "fallback_enabled": True }, "space_saver": { - "version": 1, + "version": 2, "preset": "space_saver", "qualities": { "flac": { "enabled": False, - "min_mb": 0, - "max_mb": 150, + "min_kbps": 500, + "max_kbps": 10000, "priority": 4 }, "mp3_320": { "enabled": True, - "min_mb": 0, - "max_mb": 15, + "min_kbps": 280, + "max_kbps": 500, "priority": 1 }, "mp3_256": { "enabled": True, - "min_mb": 0, - "max_mb": 12, + "min_kbps": 200, + "max_kbps": 400, "priority": 2 }, "mp3_192": { "enabled": True, - "min_mb": 0, - "max_mb": 10, + "min_kbps": 150, + "max_kbps": 300, "priority": 3 } }, diff --git a/web_server.py b/web_server.py index d7f6b551..0a6fb48c 100644 --- a/web_server.py +++ b/web_server.py @@ -392,13 +392,13 @@ def get_cached_transfer_data(): # Cache Beatport scraping data to reduce load times and avoid hammering Beatport.com beatport_data_cache = { 'homepage': { - 'hero_tracks': {'data': None, 'timestamp': 0, 'ttl': 3600}, # 1 hour - 'top_10_lists': {'data': None, 'timestamp': 0, 'ttl': 3600}, # 1 hour - 'top_10_releases': {'data': None, 'timestamp': 0, 'ttl': 3600}, # 1 hour - 'new_releases': {'data': None, 'timestamp': 0, 'ttl': 3600}, # 1 hour - 'hype_picks': {'data': None, 'timestamp': 0, 'ttl': 3600}, # 1 hour - 'featured_charts': {'data': None, 'timestamp': 0, 'ttl': 3600}, # 1 hour - 'dj_charts': {'data': None, 'timestamp': 0, 'ttl': 3600} # 1 hour + 'hero_tracks': {'data': None, 'timestamp': 0, 'ttl': 86400}, # 24 hours + 'top_10_lists': {'data': None, 'timestamp': 0, 'ttl': 86400}, # 24 hours + 'top_10_releases': {'data': None, 'timestamp': 0, 'ttl': 86400}, # 24 hours + 'new_releases': {'data': None, 'timestamp': 0, 'ttl': 86400}, # 24 hours + 'hype_picks': {'data': None, 'timestamp': 0, 'ttl': 86400}, # 24 hours + 'featured_charts': {'data': None, 'timestamp': 0, 'ttl': 86400}, # 24 hours + 'dj_charts': {'data': None, 'timestamp': 0, 'ttl': 86400} # 24 hours }, 'genre': { # Future expansion for genre-specific caching diff --git a/webui/index.html b/webui/index.html index 4944e62c..50c85645 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3001,20 +3001,20 @@
- +
- 0 MB + 500 kbps - - 150 MB + 10000 kbps
@@ -3032,20 +3032,20 @@
- +
- 0 MB + 280 kbps - - 20 MB + 500 kbps
@@ -3063,20 +3063,20 @@
- +
- 0 MB + 200 kbps - - 15 MB + 400 kbps
@@ -3094,20 +3094,20 @@
- +
- 0 MB + 150 kbps - - 12 MB + 300 kbps
@@ -3124,9 +3124,9 @@
How it works: Downloads try each enabled quality in priority order (1 = highest). - File size constraints filter out outliers - MAX limits catch fake files (500MB - "FLACs"), MIN limits (optional) can enforce minimum quality. - Set MIN to 0 to accept all file sizes (recommended for most users). + MIN bitrate catches fake/transcoded files (e.g., FLAC below 500 kbps is likely a + re-encoded MP3). MAX bitrate limits hi-res files if you want to save space. + When track duration is unavailable, a generous file-size safety net is used instead.
diff --git a/webui/static/script.js b/webui/static/script.js index 510e4ea0..975fdbca 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -568,27 +568,28 @@ async function loadPageData(pageId) { case 'library': // Check if we should return to artist detail view instead of list if (artistDetailPageState.currentArtistId && artistDetailPageState.currentArtistName) { - console.log(`🔄 Returning to artist detail: ${artistDetailPageState.currentArtistName}`); navigateToPage('artist-detail'); if (!artistDetailPageState.isInitialized) { initializeArtistDetailPage(); + loadArtistDetailData(artistDetailPageState.currentArtistId, artistDetailPageState.currentArtistName); } - loadArtistDetailData(artistDetailPageState.currentArtistId, artistDetailPageState.currentArtistName); + // Already initialized — DOM content persists, no reload needed } else { - // Initialize and load library data if (!libraryPageState.isInitialized) { initializeLibraryPage(); - } else { - // Refresh data when returning to page - await loadLibraryArtists(); } + // Already initialized — DOM content persists, no reload needed } break; case 'artist-detail': // Artist detail page is handled separately by navigateToArtistDetail() break; case 'discover': - await loadDiscoverPage(); + if (!discoverPageInitialized) { + await loadDiscoverPage(); + discoverPageInitialized = true; + } + // Already initialized — DOM content persists, no reload needed break; case 'settings': initializeSettings(); @@ -1944,8 +1945,8 @@ function populateQualityProfileUI(profile) { const minSlider = document.getElementById(`${quality}-min`); const maxSlider = document.getElementById(`${quality}-max`); if (minSlider && maxSlider) { - minSlider.value = config.min_mb; - maxSlider.value = config.max_mb; + minSlider.value = config.min_kbps; + maxSlider.value = config.max_kbps; updateQualityRange(quality); } @@ -1997,8 +1998,8 @@ function updateQualityRange(quality) { maxSlider.value = max; } - minValue.textContent = `${min} MB`; - maxValue.textContent = `${max} MB`; + minValue.textContent = `${min} kbps`; + maxValue.textContent = `${max} kbps`; } function toggleQuality(quality) { @@ -2049,7 +2050,7 @@ async function applyQualityPreset(presetName) { function collectQualityProfileFromUI() { const profile = { - version: 1, + version: 2, preset: 'custom', // Will be overridden if a preset is active qualities: {}, fallback_enabled: document.getElementById('quality-fallback-enabled')?.checked ?? true @@ -2065,8 +2066,8 @@ function collectQualityProfileFromUI() { profile.qualities[quality] = { enabled: enabled, - min_mb: parseInt(minSlider?.value || 0), - max_mb: parseInt(maxSlider?.value || 999), + min_kbps: parseInt(minSlider?.value || 0), + max_kbps: parseInt(maxSlider?.value || 99999), priority: index + 1 // 1-4 based on order }; }); @@ -30908,6 +30909,7 @@ async function selectJellyfinLibrary() { let discoverHeroIndex = 0; let discoverHeroArtists = []; let discoverHeroInterval = null; +let discoverPageInitialized = false; // Store discover playlist tracks for download/sync functionality let discoverReleaseRadarTracks = [];