preferred quality updates.
This commit is contained in:
parent
9e3c64115b
commit
576f151c5c
6 changed files with 898 additions and 36 deletions
|
|
@ -236,6 +236,12 @@ class PlexClient:
|
|||
if not self._is_connecting:
|
||||
self.ensure_connection()
|
||||
|
||||
# For status checks, only verify server connection, not music library
|
||||
# Music library might be None if user hasn't selected one yet
|
||||
return self.server is not None
|
||||
|
||||
def is_fully_configured(self) -> bool:
|
||||
"""Check if both server is connected AND music library is selected."""
|
||||
return self.server is not None and self.music_library is not None
|
||||
|
||||
def get_all_playlists(self) -> List[PlexPlaylistInfo]:
|
||||
|
|
|
|||
|
|
@ -1883,7 +1883,167 @@ class MusicDatabase:
|
|||
def get_preference(self, key: str) -> Optional[str]:
|
||||
"""Get a user preference (alias for get_metadata for clarity)"""
|
||||
return self.get_metadata(key)
|
||||
|
||||
|
||||
# Quality profile management methods
|
||||
|
||||
def get_quality_profile(self) -> dict:
|
||||
"""Get the quality profile configuration, returns default if not set"""
|
||||
import json
|
||||
|
||||
profile_json = self.get_preference('quality_profile')
|
||||
|
||||
if profile_json:
|
||||
try:
|
||||
return json.loads(profile_json)
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Failed to parse quality profile JSON, returning default")
|
||||
|
||||
# Return smart defaults (balanced preset)
|
||||
return {
|
||||
"version": 1,
|
||||
"preset": "balanced",
|
||||
"qualities": {
|
||||
"flac": {
|
||||
"enabled": True,
|
||||
"min_mb": 0,
|
||||
"max_mb": 150,
|
||||
"priority": 1
|
||||
},
|
||||
"mp3_320": {
|
||||
"enabled": True,
|
||||
"min_mb": 0,
|
||||
"max_mb": 20,
|
||||
"priority": 2
|
||||
},
|
||||
"mp3_256": {
|
||||
"enabled": True,
|
||||
"min_mb": 0,
|
||||
"max_mb": 15,
|
||||
"priority": 3
|
||||
},
|
||||
"mp3_192": {
|
||||
"enabled": False,
|
||||
"min_mb": 0,
|
||||
"max_mb": 12,
|
||||
"priority": 4
|
||||
}
|
||||
},
|
||||
"fallback_enabled": True
|
||||
}
|
||||
|
||||
def set_quality_profile(self, profile: dict) -> bool:
|
||||
"""Save quality profile configuration"""
|
||||
import json
|
||||
|
||||
try:
|
||||
profile_json = json.dumps(profile)
|
||||
self.set_preference('quality_profile', profile_json)
|
||||
logger.info(f"Quality profile saved: preset={profile.get('preset', 'custom')}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save quality profile: {e}")
|
||||
return False
|
||||
|
||||
def get_quality_preset(self, preset_name: str) -> dict:
|
||||
"""Get a predefined quality preset"""
|
||||
presets = {
|
||||
"audiophile": {
|
||||
"version": 1,
|
||||
"preset": "audiophile",
|
||||
"qualities": {
|
||||
"flac": {
|
||||
"enabled": True,
|
||||
"min_mb": 0,
|
||||
"max_mb": 200,
|
||||
"priority": 1
|
||||
},
|
||||
"mp3_320": {
|
||||
"enabled": False,
|
||||
"min_mb": 0,
|
||||
"max_mb": 20,
|
||||
"priority": 2
|
||||
},
|
||||
"mp3_256": {
|
||||
"enabled": False,
|
||||
"min_mb": 0,
|
||||
"max_mb": 15,
|
||||
"priority": 3
|
||||
},
|
||||
"mp3_192": {
|
||||
"enabled": False,
|
||||
"min_mb": 0,
|
||||
"max_mb": 12,
|
||||
"priority": 4
|
||||
}
|
||||
},
|
||||
"fallback_enabled": False
|
||||
},
|
||||
"balanced": {
|
||||
"version": 1,
|
||||
"preset": "balanced",
|
||||
"qualities": {
|
||||
"flac": {
|
||||
"enabled": True,
|
||||
"min_mb": 0,
|
||||
"max_mb": 150,
|
||||
"priority": 1
|
||||
},
|
||||
"mp3_320": {
|
||||
"enabled": True,
|
||||
"min_mb": 0,
|
||||
"max_mb": 20,
|
||||
"priority": 2
|
||||
},
|
||||
"mp3_256": {
|
||||
"enabled": True,
|
||||
"min_mb": 0,
|
||||
"max_mb": 15,
|
||||
"priority": 3
|
||||
},
|
||||
"mp3_192": {
|
||||
"enabled": False,
|
||||
"min_mb": 0,
|
||||
"max_mb": 12,
|
||||
"priority": 4
|
||||
}
|
||||
},
|
||||
"fallback_enabled": True
|
||||
},
|
||||
"space_saver": {
|
||||
"version": 1,
|
||||
"preset": "space_saver",
|
||||
"qualities": {
|
||||
"flac": {
|
||||
"enabled": False,
|
||||
"min_mb": 0,
|
||||
"max_mb": 150,
|
||||
"priority": 4
|
||||
},
|
||||
"mp3_320": {
|
||||
"enabled": True,
|
||||
"min_mb": 0,
|
||||
"max_mb": 15,
|
||||
"priority": 1
|
||||
},
|
||||
"mp3_256": {
|
||||
"enabled": True,
|
||||
"min_mb": 0,
|
||||
"max_mb": 12,
|
||||
"priority": 2
|
||||
},
|
||||
"mp3_192": {
|
||||
"enabled": True,
|
||||
"min_mb": 0,
|
||||
"max_mb": 10,
|
||||
"priority": 3
|
||||
}
|
||||
},
|
||||
"fallback_enabled": True
|
||||
}
|
||||
}
|
||||
|
||||
return presets.get(preset_name, presets["balanced"])
|
||||
|
||||
# Wishlist management methods
|
||||
|
||||
def add_to_wishlist(self, spotify_track_data: Dict[str, Any], failure_reason: str = "Download failed",
|
||||
|
|
|
|||
209
web_server.py
209
web_server.py
|
|
@ -2062,6 +2062,98 @@ def select_plex_music_library():
|
|||
logger.error(f"Error setting Plex music library: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
# ===============================
|
||||
# == QUALITY PROFILE API ==
|
||||
# ===============================
|
||||
|
||||
@app.route('/api/quality-profile', methods=['GET'])
|
||||
def get_quality_profile():
|
||||
"""Get current quality profile configuration"""
|
||||
try:
|
||||
from database.music_database import MusicDatabase
|
||||
db = MusicDatabase()
|
||||
profile = db.get_quality_profile()
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"profile": profile
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting quality profile: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/quality-profile', methods=['POST'])
|
||||
def save_quality_profile():
|
||||
"""Save quality profile configuration"""
|
||||
try:
|
||||
from database.music_database import MusicDatabase
|
||||
db = MusicDatabase()
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"success": False, "error": "No profile data provided"}), 400
|
||||
|
||||
success = db.set_quality_profile(data)
|
||||
|
||||
if success:
|
||||
add_activity_item("🎵", "Quality Profile Updated", f"Preset: {data.get('preset', 'custom')}", "Now")
|
||||
return jsonify({"success": True, "message": "Quality profile saved successfully"})
|
||||
else:
|
||||
return jsonify({"success": False, "error": "Failed to save quality profile"}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving quality profile: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/quality-profile/presets', methods=['GET'])
|
||||
def get_quality_presets():
|
||||
"""Get all available quality presets"""
|
||||
try:
|
||||
from database.music_database import MusicDatabase
|
||||
db = MusicDatabase()
|
||||
|
||||
presets = {
|
||||
"audiophile": db.get_quality_preset("audiophile"),
|
||||
"balanced": db.get_quality_preset("balanced"),
|
||||
"space_saver": db.get_quality_preset("space_saver")
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"presets": presets
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting quality presets: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/quality-profile/preset/<preset_name>', methods=['POST'])
|
||||
def apply_quality_preset(preset_name):
|
||||
"""Apply a predefined quality preset"""
|
||||
try:
|
||||
from database.music_database import MusicDatabase
|
||||
db = MusicDatabase()
|
||||
|
||||
preset = db.get_quality_preset(preset_name)
|
||||
success = db.set_quality_profile(preset)
|
||||
|
||||
if success:
|
||||
add_activity_item("🎵", "Quality Preset Applied", f"Applied '{preset_name}' preset", "Now")
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": f"Applied '{preset_name}' preset",
|
||||
"profile": preset
|
||||
})
|
||||
else:
|
||||
return jsonify({"success": False, "error": "Failed to apply preset"}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying quality preset: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
# ===============================
|
||||
# == END QUALITY PROFILE API ==
|
||||
# ===============================
|
||||
|
||||
@app.route('/api/detect-soulseek', methods=['POST'])
|
||||
def detect_soulseek_endpoint():
|
||||
print("Received auto-detect request for slskd")
|
||||
|
|
@ -7790,29 +7882,30 @@ def stop_database_update():
|
|||
|
||||
def _filter_candidates_by_quality_preference(candidates):
|
||||
"""
|
||||
Filter candidates based on user's quality preference.
|
||||
Returns candidates of the preferred quality, sorted by size (largest first for best 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 config.settings import config_manager
|
||||
from database.music_database import MusicDatabase
|
||||
|
||||
user_preference = config_manager.get_quality_preference() # flac, mp3_320, mp3_256, mp3_192, any
|
||||
# Get quality profile from database
|
||||
db = MusicDatabase()
|
||||
profile = db.get_quality_profile()
|
||||
|
||||
# If user wants 'any' quality, return all candidates (already sorted by confidence+size)
|
||||
if user_preference == 'any':
|
||||
return candidates
|
||||
print(f"🎵 [Quality Filter] Using profile preset: '{profile.get('preset', 'custom')}', filtering {len(candidates)} candidates")
|
||||
|
||||
print(f"🎵 [Quality Filter] User preference: '{user_preference}', filtering {len(candidates)} candidates")
|
||||
|
||||
# Categorize candidates by quality
|
||||
# Categorize candidates by quality with file size constraints
|
||||
quality_buckets = {
|
||||
'flac': [],
|
||||
'mp3_320': [],
|
||||
'mp3_256': [],
|
||||
'mp3_192': [],
|
||||
'mp3_low': [],
|
||||
'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)
|
||||
|
|
@ -7820,38 +7913,94 @@ def _filter_candidates_by_quality_preference(candidates):
|
|||
|
||||
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_buckets['flac'].append(candidate)
|
||||
elif track_format == 'mp3':
|
||||
if track_bitrate >= 320:
|
||||
quality_buckets['mp3_320'].append(candidate)
|
||||
elif track_bitrate >= 256:
|
||||
quality_buckets['mp3_256'].append(candidate)
|
||||
elif track_bitrate >= 192:
|
||||
quality_buckets['mp3_192'].append(candidate)
|
||||
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:
|
||||
quality_buckets['mp3_low'].append(candidate)
|
||||
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 size (largest first) to get best quality within each category
|
||||
# Sort each bucket by quality score and size
|
||||
for bucket in quality_buckets.values():
|
||||
bucket.sort(key=lambda x: x.size, reverse=True)
|
||||
|
||||
# Return candidates matching user preference
|
||||
preferred_candidates = quality_buckets.get(user_preference, [])
|
||||
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")
|
||||
print(f"🎵 [Quality Filter] Found {len(bucket)} '{quality}' candidates (after size filtering)")
|
||||
|
||||
if preferred_candidates:
|
||||
print(f"🎯 [Quality Filter] Returning {len(preferred_candidates)} '{user_preference}' candidates")
|
||||
return preferred_candidates
|
||||
# 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 '{user_preference}' candidates found, will fall back to all")
|
||||
print(f"❌ [Quality Filter] No enabled qualities matched and fallback is disabled, returning empty")
|
||||
return []
|
||||
|
||||
def get_valid_candidates(results, spotify_track, query):
|
||||
|
|
|
|||
141
webui/index.html
141
webui/index.html
|
|
@ -1744,7 +1744,146 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Quality Profile Settings -->
|
||||
<div class="settings-group">
|
||||
<h3>🎵 Quality Profile</h3>
|
||||
|
||||
<!-- Presets -->
|
||||
<div class="quality-presets">
|
||||
<label>Quick Presets:</label>
|
||||
<div class="preset-buttons">
|
||||
<button class="preset-button" onclick="applyQualityPreset('audiophile')" title="FLAC only, strict size constraints">
|
||||
🎧 Audiophile
|
||||
</button>
|
||||
<button class="preset-button active" onclick="applyQualityPreset('balanced')" title="FLAC preferred, MP3 fallback">
|
||||
⚖️ Balanced
|
||||
</button>
|
||||
<button class="preset-button" onclick="applyQualityPreset('space_saver')" title="MP3 preferred, smaller sizes">
|
||||
💾 Space Saver
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FLAC Quality -->
|
||||
<div class="quality-tier">
|
||||
<div class="quality-tier-header">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="quality-flac-enabled" checked onchange="toggleQuality('flac')">
|
||||
<span class="quality-tier-name">FLAC (Lossless)</span>
|
||||
</label>
|
||||
<span class="quality-tier-priority" id="priority-flac">Priority: 1</span>
|
||||
</div>
|
||||
<div class="quality-tier-sliders" id="sliders-flac">
|
||||
<div class="slider-group">
|
||||
<label>File Size 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" oninput="updateQualityRange('flac')">
|
||||
<input type="range" class="range-slider range-slider-max" id="flac-max" min="0" max="200" value="150" step="5" oninput="updateQualityRange('flac')">
|
||||
<div class="range-slider-track"></div>
|
||||
</div>
|
||||
<div class="slider-values">
|
||||
<span id="flac-min-value">0 MB</span>
|
||||
<span>-</span>
|
||||
<span id="flac-max-value">150 MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MP3 320 Quality -->
|
||||
<div class="quality-tier">
|
||||
<div class="quality-tier-header">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="quality-mp3_320-enabled" checked onchange="toggleQuality('mp3_320')">
|
||||
<span class="quality-tier-name">MP3 320 kbps</span>
|
||||
</label>
|
||||
<span class="quality-tier-priority" id="priority-mp3_320">Priority: 2</span>
|
||||
</div>
|
||||
<div class="quality-tier-sliders" id="sliders-mp3_320">
|
||||
<div class="slider-group">
|
||||
<label>File Size 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" 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" 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>-</span>
|
||||
<span id="mp3_320-max-value">20 MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MP3 256 Quality -->
|
||||
<div class="quality-tier">
|
||||
<div class="quality-tier-header">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="quality-mp3_256-enabled" checked onchange="toggleQuality('mp3_256')">
|
||||
<span class="quality-tier-name">MP3 256 kbps</span>
|
||||
</label>
|
||||
<span class="quality-tier-priority" id="priority-mp3_256">Priority: 3</span>
|
||||
</div>
|
||||
<div class="quality-tier-sliders" id="sliders-mp3_256">
|
||||
<div class="slider-group">
|
||||
<label>File Size 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" 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" 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>-</span>
|
||||
<span id="mp3_256-max-value">15 MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MP3 192 Quality -->
|
||||
<div class="quality-tier">
|
||||
<div class="quality-tier-header">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="quality-mp3_192-enabled" onchange="toggleQuality('mp3_192')">
|
||||
<span class="quality-tier-name">MP3 192 kbps</span>
|
||||
</label>
|
||||
<span class="quality-tier-priority" id="priority-mp3_192">Priority: 4</span>
|
||||
</div>
|
||||
<div class="quality-tier-sliders disabled" id="sliders-mp3_192">
|
||||
<div class="slider-group">
|
||||
<label>File Size 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" 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" 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>-</span>
|
||||
<span id="mp3_192-max-value">12 MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fallback Option -->
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="quality-fallback-enabled" checked>
|
||||
Allow fallback to any quality if preferred qualities unavailable
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Database Settings -->
|
||||
<div class="settings-group">
|
||||
<h3>Database Settings</h3>
|
||||
|
|
|
|||
|
|
@ -442,6 +442,7 @@ async function loadPageData(pageId) {
|
|||
case 'settings':
|
||||
initializeSettings();
|
||||
await loadSettingsData();
|
||||
await loadQualityProfile();
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -1518,6 +1519,218 @@ function toggleServer(serverType) {
|
|||
}
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// QUALITY PROFILE FUNCTIONS
|
||||
// ===============================
|
||||
|
||||
let currentQualityProfile = null;
|
||||
|
||||
async function loadQualityProfile() {
|
||||
try {
|
||||
const response = await fetch('/api/quality-profile');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
currentQualityProfile = data.profile;
|
||||
populateQualityProfileUI(currentQualityProfile);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading quality profile:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function populateQualityProfileUI(profile) {
|
||||
// Update preset buttons
|
||||
document.querySelectorAll('.preset-button').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
const activePresetBtn = document.querySelector(`.preset-button[onclick*="${profile.preset}"]`);
|
||||
if (activePresetBtn) {
|
||||
activePresetBtn.classList.add('active');
|
||||
}
|
||||
|
||||
// Populate each quality tier
|
||||
const qualities = ['flac', 'mp3_320', 'mp3_256', 'mp3_192'];
|
||||
qualities.forEach(quality => {
|
||||
const config = profile.qualities[quality];
|
||||
if (config) {
|
||||
// Set enabled checkbox
|
||||
const enabledCheckbox = document.getElementById(`quality-${quality}-enabled`);
|
||||
if (enabledCheckbox) {
|
||||
enabledCheckbox.checked = config.enabled;
|
||||
}
|
||||
|
||||
// Set min/max sliders
|
||||
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;
|
||||
updateQualityRange(quality);
|
||||
}
|
||||
|
||||
// Set priority display
|
||||
const prioritySpan = document.getElementById(`priority-${quality}`);
|
||||
if (prioritySpan) {
|
||||
prioritySpan.textContent = `Priority: ${config.priority}`;
|
||||
}
|
||||
|
||||
// Toggle sliders visibility
|
||||
const sliders = document.getElementById(`sliders-${quality}`);
|
||||
if (sliders) {
|
||||
if (config.enabled) {
|
||||
sliders.classList.remove('disabled');
|
||||
} else {
|
||||
sliders.classList.add('disabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set fallback checkbox
|
||||
const fallbackCheckbox = document.getElementById('quality-fallback-enabled');
|
||||
if (fallbackCheckbox) {
|
||||
fallbackCheckbox.checked = profile.fallback_enabled;
|
||||
}
|
||||
}
|
||||
|
||||
function updateQualityRange(quality) {
|
||||
const minSlider = document.getElementById(`${quality}-min`);
|
||||
const maxSlider = document.getElementById(`${quality}-max`);
|
||||
const minValue = document.getElementById(`${quality}-min-value`);
|
||||
const maxValue = document.getElementById(`${quality}-max-value`);
|
||||
|
||||
if (!minSlider || !maxSlider || !minValue || !maxValue) return;
|
||||
|
||||
let min = parseInt(minSlider.value);
|
||||
let max = parseInt(maxSlider.value);
|
||||
|
||||
// Ensure min doesn't exceed max
|
||||
if (min > max) {
|
||||
min = max;
|
||||
minSlider.value = min;
|
||||
}
|
||||
|
||||
// Ensure max doesn't go below min
|
||||
if (max < min) {
|
||||
max = min;
|
||||
maxSlider.value = max;
|
||||
}
|
||||
|
||||
minValue.textContent = `${min} MB`;
|
||||
maxValue.textContent = `${max} MB`;
|
||||
}
|
||||
|
||||
function toggleQuality(quality) {
|
||||
const checkbox = document.getElementById(`quality-${quality}-enabled`);
|
||||
const sliders = document.getElementById(`sliders-${quality}`);
|
||||
|
||||
if (checkbox && sliders) {
|
||||
if (checkbox.checked) {
|
||||
sliders.classList.remove('disabled');
|
||||
} else {
|
||||
sliders.classList.add('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
// Mark preset as custom when manually changing
|
||||
if (currentQualityProfile) {
|
||||
currentQualityProfile.preset = 'custom';
|
||||
document.querySelectorAll('.preset-button').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function applyQualityPreset(presetName) {
|
||||
try {
|
||||
showLoadingOverlay(`Applying ${presetName} preset...`);
|
||||
|
||||
const response = await fetch(`/api/quality-profile/preset/${presetName}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
currentQualityProfile = data.profile;
|
||||
populateQualityProfileUI(currentQualityProfile);
|
||||
showToast(`Applied '${presetName}' preset`, 'success');
|
||||
} else {
|
||||
showToast(`Failed to apply preset: ${data.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error applying quality preset:', error);
|
||||
showToast('Failed to apply preset', 'error');
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
function collectQualityProfileFromUI() {
|
||||
const profile = {
|
||||
version: 1,
|
||||
preset: 'custom', // Will be overridden if a preset is active
|
||||
qualities: {},
|
||||
fallback_enabled: document.getElementById('quality-fallback-enabled')?.checked || true
|
||||
};
|
||||
|
||||
const qualities = ['flac', 'mp3_320', 'mp3_256', 'mp3_192'];
|
||||
let priority = 1;
|
||||
|
||||
qualities.forEach((quality, index) => {
|
||||
const enabled = document.getElementById(`quality-${quality}-enabled`)?.checked || false;
|
||||
const minSlider = document.getElementById(`${quality}-min`);
|
||||
const maxSlider = document.getElementById(`${quality}-max`);
|
||||
|
||||
profile.qualities[quality] = {
|
||||
enabled: enabled,
|
||||
min_mb: parseInt(minSlider?.value || 0),
|
||||
max_mb: parseInt(maxSlider?.value || 999),
|
||||
priority: index + 1 // 1-4 based on order
|
||||
};
|
||||
});
|
||||
|
||||
// Check if current profile matches a preset
|
||||
if (currentQualityProfile && currentQualityProfile.preset !== 'custom') {
|
||||
profile.preset = currentQualityProfile.preset;
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
async function saveQualityProfile() {
|
||||
try {
|
||||
const profile = collectQualityProfileFromUI();
|
||||
|
||||
const response = await fetch('/api/quality-profile', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(profile)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
currentQualityProfile = profile;
|
||||
console.log('Quality profile saved successfully');
|
||||
return true;
|
||||
} else {
|
||||
console.error('Failed to save quality profile:', data.error);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving quality profile:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// END QUALITY PROFILE FUNCTIONS
|
||||
// ===============================
|
||||
|
||||
async function saveSettings() {
|
||||
// Determine active server from toggle buttons
|
||||
let activeServer = 'plex';
|
||||
|
|
@ -1575,19 +1788,26 @@ async function saveSettings() {
|
|||
|
||||
try {
|
||||
showLoadingOverlay('Saving settings...');
|
||||
|
||||
|
||||
// Save main settings
|
||||
const response = await fetch(API.settings, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
|
||||
// Save quality profile
|
||||
const qualityProfileSaved = await saveQualityProfile();
|
||||
|
||||
if (result.success && qualityProfileSaved) {
|
||||
showToast('Settings saved successfully', 'success');
|
||||
// Trigger immediate status update
|
||||
setTimeout(updateServiceStatus, 1000);
|
||||
} else if (result.success && !qualityProfileSaved) {
|
||||
showToast('Settings saved, but quality profile failed to save', 'warning');
|
||||
setTimeout(updateServiceStatus, 1000);
|
||||
} else {
|
||||
showToast(`Failed to save settings: ${result.error}`, 'error');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1263,6 +1263,194 @@ body {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== QUALITY PROFILE STYLES ===== */
|
||||
|
||||
.quality-presets {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.quality-presets label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.preset-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preset-button {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preset-button:hover {
|
||||
background: rgba(29, 185, 84, 0.1);
|
||||
border-color: rgba(29, 185, 84, 0.3);
|
||||
color: #1ed760;
|
||||
}
|
||||
|
||||
.preset-button.active {
|
||||
background: rgba(29, 185, 84, 0.15);
|
||||
border-color: rgba(29, 185, 84, 0.5);
|
||||
color: #1ed760;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quality-tier {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.quality-tier-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.quality-tier-name {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quality-tier-priority {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.quality-tier-sliders {
|
||||
padding-left: 24px;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.quality-tier-sliders.disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slider-group {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.slider-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dual-slider-container {
|
||||
position: relative;
|
||||
height: 40px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.range-slider {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.range-slider::-webkit-slider-track {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.range-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #1ed760;
|
||||
cursor: pointer;
|
||||
pointer-events: all;
|
||||
border: 2px solid #0a0a0a;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.range-slider::-webkit-slider-thumb:hover {
|
||||
background: #1fdf64;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.range-slider::-moz-range-track {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.range-slider::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #1ed760;
|
||||
cursor: pointer;
|
||||
pointer-events: all;
|
||||
border: 2px solid #0a0a0a;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.range-slider::-moz-range-thumb:hover {
|
||||
background: #1fdf64;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.range-slider-track {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(29, 185, 84, 0.3);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slider-values {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.slider-values span:first-child,
|
||||
.slider-values span:last-child {
|
||||
color: #1ed760;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ===== END QUALITY PROFILE STYLES ===== */
|
||||
|
||||
.test-button {
|
||||
background: rgba(29, 185, 84, 0.1);
|
||||
color: #1ed760;
|
||||
|
|
|
|||
Loading…
Reference in a new issue