Quality filter: use bitrate density instead of file size, cache Library/Discover pages, extend Beatport cache to 24h

pull/153/head
Broque Thomas 2 months ago
parent acb26777ca
commit 8cf0950d3b

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

@ -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
}
},

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

@ -3001,20 +3001,20 @@
</div>
<div class="quality-tier-sliders" id="sliders-flac">
<div class="slider-group">
<label>File Size Range:</label>
<label>Bitrate Range:</label>
<div class="dual-slider-container">
<input type="range" class="range-slider range-slider-min" id="flac-min"
min="0" max="200" value="0" step="5"
min="0" max="10000" value="500" step="100"
oninput="updateQualityRange('flac')">
<input type="range" class="range-slider range-slider-max" id="flac-max"
min="0" max="200" value="150" step="5"
min="0" max="10000" value="10000" step="100"
oninput="updateQualityRange('flac')">
<div class="range-slider-track"></div>
</div>
<div class="slider-values">
<span id="flac-min-value">0 MB</span>
<span id="flac-min-value">500 kbps</span>
<span>-</span>
<span id="flac-max-value">150 MB</span>
<span id="flac-max-value">10000 kbps</span>
</div>
</div>
</div>
@ -3032,20 +3032,20 @@
</div>
<div class="quality-tier-sliders" id="sliders-mp3_320">
<div class="slider-group">
<label>File Size Range:</label>
<label>Bitrate Range:</label>
<div class="dual-slider-container">
<input type="range" class="range-slider range-slider-min"
id="mp3_320-min" min="0" max="50" value="0" step="1"
id="mp3_320-min" min="0" max="500" value="280" step="10"
oninput="updateQualityRange('mp3_320')">
<input type="range" class="range-slider range-slider-max"
id="mp3_320-max" min="0" max="50" value="20" step="1"
id="mp3_320-max" min="0" max="500" value="500" step="10"
oninput="updateQualityRange('mp3_320')">
<div class="range-slider-track"></div>
</div>
<div class="slider-values">
<span id="mp3_320-min-value">0 MB</span>
<span id="mp3_320-min-value">280 kbps</span>
<span>-</span>
<span id="mp3_320-max-value">20 MB</span>
<span id="mp3_320-max-value">500 kbps</span>
</div>
</div>
</div>
@ -3063,20 +3063,20 @@
</div>
<div class="quality-tier-sliders" id="sliders-mp3_256">
<div class="slider-group">
<label>File Size Range:</label>
<label>Bitrate Range:</label>
<div class="dual-slider-container">
<input type="range" class="range-slider range-slider-min"
id="mp3_256-min" min="0" max="40" value="0" step="1"
id="mp3_256-min" min="0" max="400" value="200" step="10"
oninput="updateQualityRange('mp3_256')">
<input type="range" class="range-slider range-slider-max"
id="mp3_256-max" min="0" max="40" value="15" step="1"
id="mp3_256-max" min="0" max="400" value="400" step="10"
oninput="updateQualityRange('mp3_256')">
<div class="range-slider-track"></div>
</div>
<div class="slider-values">
<span id="mp3_256-min-value">0 MB</span>
<span id="mp3_256-min-value">200 kbps</span>
<span>-</span>
<span id="mp3_256-max-value">15 MB</span>
<span id="mp3_256-max-value">400 kbps</span>
</div>
</div>
</div>
@ -3094,20 +3094,20 @@
</div>
<div class="quality-tier-sliders disabled" id="sliders-mp3_192">
<div class="slider-group">
<label>File Size Range:</label>
<label>Bitrate Range:</label>
<div class="dual-slider-container">
<input type="range" class="range-slider range-slider-min"
id="mp3_192-min" min="0" max="30" value="0" step="1"
id="mp3_192-min" min="0" max="300" value="150" step="10"
oninput="updateQualityRange('mp3_192')">
<input type="range" class="range-slider range-slider-max"
id="mp3_192-max" min="0" max="30" value="12" step="1"
id="mp3_192-max" min="0" max="300" value="300" step="10"
oninput="updateQualityRange('mp3_192')">
<div class="range-slider-track"></div>
</div>
<div class="slider-values">
<span id="mp3_192-min-value">0 MB</span>
<span id="mp3_192-min-value">150 kbps</span>
<span>-</span>
<span id="mp3_192-max-value">12 MB</span>
<span id="mp3_192-max-value">300 kbps</span>
</div>
</div>
</div>
@ -3124,9 +3124,9 @@
<div class="help-text">
<strong>How it works:</strong> 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.
</div>
</div>
</div>

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

Loading…
Cancel
Save