feat: global AudioQuality model with post-download quarantine + retry
Replaces the Soulseek-only bit-depth heuristic with a source-agnostic
quality system that works across all download sources.
## core/quality/model.py (new)
- AudioQuality dataclass: format, bitrate, sample_rate, bit_depth
- QualityTarget: one ranked entry in the user's priority list
- filter_and_rank(): source-neutral candidate ranking
- rank_candidate(): scores any AudioQuality against ranked_targets
- v2_qualities_to_ranked_targets(): migration helper
## core/download_plugins/types.py
- SearchResult gains sample_rate + bit_depth fields
- audio_quality property returns unified AudioQuality
- AlbumResult gets audio_quality aggregated from tracks
## core/soulseek_client.py
- Parses slskd attributes array (type 4=sample_rate, type 5=bit_depth)
- Real values instead of kbps heuristic
- filter_results_by_quality_preference() replaced by filter_and_rank()
## database/music_database.py
- Quality profile v3 with ranked_targets list
- Auto-migration v2 → v3 on load
- Presets (audiophile/balanced/space_saver) updated to v3
## core/imports/file_ops.py
- probe_audio_quality(): reads actual downloaded file via mutagen
returns AudioQuality with ground-truth values
## core/imports/guards.py
- check_quality_target(): replaces check_flac_bit_depth
checks all formats/sources against ranked_targets
- check_flac_bit_depth() kept as backwards-compat wrapper
## core/imports/pipeline.py
- Uses check_quality_target() instead of check_flac_bit_depth()
- Quality mismatch triggers _requeue_quarantined_task_for_retry('quality')
so next-best candidate is tried before failing (same as AcoustID)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
78f47f04d7
commit
2c91af0062
8 changed files with 531 additions and 315 deletions
|
|
@ -13,10 +13,11 @@ import from a neutral package per Cin's contract-first standard.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.imports.filename import parse_filename_metadata
|
||||
from core.quality.model import AudioQuality
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -32,6 +33,20 @@ class SearchResult:
|
|||
upload_speed: int
|
||||
queue_length: int
|
||||
result_type: str = "track" # "track" or "album"
|
||||
# Rich quality metadata — populated by sources that provide it.
|
||||
# None means "unknown", not "absent".
|
||||
sample_rate: Optional[int] = None # Hz (e.g. 44100, 96000, 192000)
|
||||
bit_depth: Optional[int] = None # bits per sample (16, 24)
|
||||
|
||||
@property
|
||||
def audio_quality(self) -> AudioQuality:
|
||||
"""Unified quality descriptor derived from this result's fields."""
|
||||
return AudioQuality(
|
||||
format=self.quality.lower() if self.quality else 'unknown',
|
||||
bitrate=self.bitrate,
|
||||
sample_rate=self.sample_rate,
|
||||
bit_depth=self.bit_depth,
|
||||
)
|
||||
|
||||
@property
|
||||
def quality_score(self) -> float:
|
||||
|
|
@ -127,6 +142,19 @@ class AlbumResult:
|
|||
queue_length: int = 0
|
||||
result_type: str = "album"
|
||||
|
||||
@property
|
||||
def audio_quality(self) -> AudioQuality:
|
||||
"""Unified quality descriptor derived from dominant track quality."""
|
||||
sample_rates = [t.sample_rate for t in self.tracks if t.sample_rate]
|
||||
bit_depths = [t.bit_depth for t in self.tracks if t.bit_depth]
|
||||
bitrates = [t.bitrate for t in self.tracks if t.bitrate]
|
||||
return AudioQuality(
|
||||
format=self.dominant_quality.lower() if self.dominant_quality else 'unknown',
|
||||
bitrate=max(bitrates) if bitrates else None,
|
||||
sample_rate=max(sample_rates) if sample_rates else None,
|
||||
bit_depth=max(bit_depths) if bit_depths else None,
|
||||
)
|
||||
|
||||
@property
|
||||
def quality_score(self) -> float:
|
||||
"""Calculate album quality score based on dominant quality and track count"""
|
||||
|
|
|
|||
|
|
@ -225,6 +225,79 @@ def get_audio_quality_string(file_path):
|
|||
return ""
|
||||
|
||||
|
||||
def probe_audio_quality(file_path: str):
|
||||
"""Read the actual file and return an AudioQuality with real measured values.
|
||||
|
||||
Uses mutagen to extract sample_rate, bit_depth, and bitrate from the
|
||||
downloaded file — these are ground-truth values, not estimates.
|
||||
Returns None when the file cannot be read.
|
||||
"""
|
||||
from core.quality.model import AudioQuality
|
||||
try:
|
||||
ext = os.path.splitext(file_path)[1].lower().lstrip('.')
|
||||
|
||||
if ext == 'flac':
|
||||
from mutagen.flac import FLAC
|
||||
audio = FLAC(file_path)
|
||||
return AudioQuality(
|
||||
format='flac',
|
||||
bitrate=audio.info.bitrate // 1000 if audio.info.bitrate else None,
|
||||
sample_rate=audio.info.sample_rate,
|
||||
bit_depth=audio.info.bits_per_sample,
|
||||
)
|
||||
|
||||
if ext == 'mp3':
|
||||
from mutagen.mp3 import MP3
|
||||
audio = MP3(file_path)
|
||||
return AudioQuality(
|
||||
format='mp3',
|
||||
bitrate=audio.info.bitrate // 1000,
|
||||
sample_rate=audio.info.sample_rate,
|
||||
)
|
||||
|
||||
if ext in ('m4a', 'aac', 'mp4'):
|
||||
from mutagen.mp4 import MP4
|
||||
audio = MP4(file_path)
|
||||
return AudioQuality(
|
||||
format='aac',
|
||||
bitrate=audio.info.bitrate // 1000,
|
||||
sample_rate=audio.info.sample_rate,
|
||||
)
|
||||
|
||||
if ext == 'ogg':
|
||||
from mutagen.oggvorbis import OggVorbis
|
||||
audio = OggVorbis(file_path)
|
||||
return AudioQuality(
|
||||
format='ogg',
|
||||
bitrate=audio.info.bitrate // 1000,
|
||||
sample_rate=audio.info.sample_rate,
|
||||
)
|
||||
|
||||
if ext == 'opus':
|
||||
from mutagen.oggopus import OggOpus
|
||||
audio = OggOpus(file_path)
|
||||
return AudioQuality(
|
||||
format='opus',
|
||||
bitrate=audio.info.bitrate // 1000,
|
||||
sample_rate=audio.info.sample_rate,
|
||||
)
|
||||
|
||||
if ext in ('wav', 'aiff', 'aif'):
|
||||
from mutagen.wave import WAVE
|
||||
audio = WAVE(file_path)
|
||||
return AudioQuality(
|
||||
format='wav',
|
||||
bitrate=audio.info.bitrate // 1000 if audio.info.bitrate else None,
|
||||
sample_rate=audio.info.sample_rate,
|
||||
bit_depth=getattr(audio.info, 'bits_per_sample', None),
|
||||
)
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("probe_audio_quality failed for %s: %s", file_path, e)
|
||||
return None
|
||||
|
||||
|
||||
def get_quality_tier_from_extension(file_path):
|
||||
"""Classify a file extension into a quality tier."""
|
||||
if not file_path:
|
||||
|
|
|
|||
|
|
@ -105,30 +105,66 @@ def move_to_quarantine(file_path: str, context: dict, reason: str, automation_en
|
|||
|
||||
|
||||
def check_flac_bit_depth(file_path: str, context: dict) -> Optional[str]:
|
||||
"""Return a rejection message if a FLAC file violates the configured bit depth."""
|
||||
if not context.get("_audio_quality", "").startswith("FLAC"):
|
||||
"""Legacy wrapper — delegates to check_quality_target.
|
||||
|
||||
Kept for callers that still pass trigger='bit_depth'; the new guard
|
||||
covers bit_depth as part of the full quality target check.
|
||||
"""
|
||||
return check_quality_target(file_path, context)
|
||||
|
||||
|
||||
def check_quality_target(file_path: str, context: dict) -> Optional[str]:
|
||||
"""Return a rejection message when the downloaded file does not satisfy
|
||||
the user's quality priority list.
|
||||
|
||||
Probes the actual file with mutagen (ground-truth sample_rate,
|
||||
bit_depth, bitrate) and checks it against the profile's
|
||||
``ranked_targets``. Falls back gracefully when fallback_enabled=True.
|
||||
|
||||
Works for all formats and all download sources — no Soulseek-specific
|
||||
logic here.
|
||||
"""
|
||||
from core.imports.file_ops import probe_audio_quality
|
||||
from core.quality.model import QualityTarget, rank_candidate, v2_qualities_to_ranked_targets
|
||||
|
||||
aq = probe_audio_quality(file_path)
|
||||
if aq is None:
|
||||
logger.debug("[QualityGuard] Could not probe %s — skipping check", os.path.basename(file_path))
|
||||
return None
|
||||
|
||||
quality_profile = MusicDatabase().get_quality_profile()
|
||||
flac_config = quality_profile.get("qualities", {}).get("flac", {})
|
||||
flac_pref = flac_config.get("bit_depth", "any")
|
||||
if flac_pref == "any":
|
||||
profile = MusicDatabase().get_quality_profile()
|
||||
raw_targets = profile.get("ranked_targets")
|
||||
if not raw_targets and "qualities" in profile:
|
||||
raw_targets = v2_qualities_to_ranked_targets(profile["qualities"])
|
||||
|
||||
if not raw_targets:
|
||||
return None
|
||||
|
||||
actual_bits = context["_audio_quality"].replace("FLAC ", "").replace("bit", "")
|
||||
if actual_bits == flac_pref:
|
||||
return None
|
||||
|
||||
flac_fallback = flac_config.get("bit_depth_fallback", True)
|
||||
targets = [QualityTarget.from_dict(t) for t in raw_targets]
|
||||
fallback_enabled = profile.get("fallback_enabled", True)
|
||||
downsample_enabled = _get_config_manager().get("lossy_copy.downsample_hires", False)
|
||||
|
||||
target_idx, _ = rank_candidate(aq, targets)
|
||||
matched = target_idx < len(targets)
|
||||
|
||||
track_info = context.get("track_info", {})
|
||||
track_name = track_info.get("name", os.path.basename(file_path))
|
||||
actual_label = aq.label()
|
||||
|
||||
if flac_fallback or downsample_enabled:
|
||||
if downsample_enabled:
|
||||
logger.info("[FLAC Downsample] Accepted %s-bit FLAC (will be downsampled to %s-bit): %s", actual_bits, flac_pref, track_name)
|
||||
else:
|
||||
logger.warning("[FLAC Fallback] Accepted %s-bit FLAC (preferred %s-bit): %s", actual_bits, flac_pref, track_name)
|
||||
if matched:
|
||||
logger.info("[QualityGuard] %s matched target '%s': %s", track_name, targets[target_idx].label, actual_label)
|
||||
return None
|
||||
|
||||
return f"FLAC bit depth mismatch: file is {actual_bits}-bit, preference is {flac_pref}-bit"
|
||||
# No target matched
|
||||
best_label = targets[0].label if targets else "?"
|
||||
if fallback_enabled or downsample_enabled:
|
||||
logger.warning(
|
||||
"[QualityGuard] %s did not match any target (got %s, wanted %s) — accepting via fallback",
|
||||
track_name, actual_label, best_label,
|
||||
)
|
||||
return None
|
||||
|
||||
return (
|
||||
f"Quality mismatch: file is {actual_label}, "
|
||||
f"does not satisfy any configured target (best wanted: {best_label})"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ from core.imports.context import (
|
|||
)
|
||||
from core.imports.file_integrity import check_audio_integrity, expected_duration_for_check, resolve_duration_tolerance
|
||||
from core.imports.filename import extract_track_number_from_filename
|
||||
from core.imports.guards import check_flac_bit_depth, move_to_quarantine
|
||||
from core.imports.guards import check_flac_bit_depth, check_quality_target, move_to_quarantine
|
||||
from core.imports.quarantine import (
|
||||
approve_quarantine_entry,
|
||||
entry_id_from_quarantined_filename,
|
||||
|
|
@ -160,7 +160,7 @@ def import_rejection_reason(context: dict) -> str | None:
|
|||
f"{context.get('_acoustid_failure_msg', 'fingerprint mismatch')}"
|
||||
)
|
||||
if context.get('_bitdepth_rejected'):
|
||||
return "rejected by bit-depth filter"
|
||||
return "rejected by quality filter"
|
||||
if context.get('_race_guard_failed'):
|
||||
return "source file disappeared before import completed"
|
||||
return None
|
||||
|
|
@ -581,10 +581,11 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
|
|||
if context['_audio_quality']:
|
||||
logger.info(f"Audio quality detected: {context['_audio_quality']}")
|
||||
|
||||
_skip_bit_depth = _should_skip_quarantine_check(context, 'bit_depth')
|
||||
rejection_reason = None if _skip_bit_depth else check_flac_bit_depth(file_path, context)
|
||||
if _skip_bit_depth:
|
||||
logger.info(f"[BitDepth] Skipped (user approval) for {_basename}")
|
||||
_skip_quality = _should_skip_quarantine_check(context, 'bit_depth') or \
|
||||
_should_skip_quarantine_check(context, 'quality')
|
||||
rejection_reason = None if _skip_quality else check_quality_target(file_path, context)
|
||||
if _skip_quality:
|
||||
logger.info(f"[QualityGuard] Skipped (user approval) for {_basename}")
|
||||
if rejection_reason:
|
||||
try:
|
||||
quarantine_path = move_to_quarantine(
|
||||
|
|
@ -592,10 +593,10 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
|
|||
context,
|
||||
rejection_reason,
|
||||
automation_engine,
|
||||
trigger='bit_depth',
|
||||
trigger='quality',
|
||||
)
|
||||
_mark_task_quarantined(context, quarantine_path)
|
||||
logger.info(f"File quarantined due to bit depth filter: {quarantine_path}")
|
||||
logger.info(f"File quarantined due to quality mismatch: {quarantine_path}")
|
||||
except Exception as quarantine_error:
|
||||
logger.error(f"Quarantine failed ({quarantine_error}), deleting file: {file_path}")
|
||||
try:
|
||||
|
|
@ -604,17 +605,25 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
|
|||
logger.debug("delete quarantine fallback: %s", e)
|
||||
|
||||
context['_bitdepth_rejected'] = True
|
||||
with matched_context_lock:
|
||||
if context_key in matched_downloads_context:
|
||||
del matched_downloads_context[context_key]
|
||||
|
||||
task_id = context.get('task_id')
|
||||
batch_id = context.get('batch_id')
|
||||
with matched_context_lock:
|
||||
matched_downloads_context.pop(context_key, None)
|
||||
|
||||
# Try the next-best candidate before giving up — same pattern
|
||||
# as AcoustID and integrity failures.
|
||||
if _requeue_quarantined_task_for_retry(task_id, batch_id, 'quality'):
|
||||
logger.info(
|
||||
"Quality mismatch for task %s — retrying next-best candidate: %s",
|
||||
task_id, rejection_reason,
|
||||
)
|
||||
return
|
||||
|
||||
if task_id:
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = f"Bit depth filter: {rejection_reason}"
|
||||
download_tasks[task_id]['error_message'] = f"Quality filter: {rejection_reason}"
|
||||
if task_id and batch_id:
|
||||
_notify_download_completed(batch_id, task_id, success=False)
|
||||
return
|
||||
|
|
|
|||
0
core/quality/__init__.py
Normal file
0
core/quality/__init__.py
Normal file
261
core/quality/model.py
Normal file
261
core/quality/model.py
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
"""Source-agnostic audio quality model.
|
||||
|
||||
Every download source maps its result into ``AudioQuality``.
|
||||
The ``QualityTarget`` list in the user's profile defines the
|
||||
priority order (1st choice, 2nd choice, …). ``rank_candidate``
|
||||
scores any ``AudioQuality`` against that list so the same
|
||||
logic drives Soulseek, Tidal, Deezer, torrent — no per-source
|
||||
quality pipelines needed.
|
||||
|
||||
Soulseek attribute type codes (Soulseek protocol spec):
|
||||
0 = bitrate (kbps)
|
||||
1 = duration (seconds)
|
||||
2 = VBR flag
|
||||
4 = sample rate (Hz) — FLAC / WAV only
|
||||
5 = bit depth — FLAC / WAV only
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioQuality:
|
||||
"""Unified audio quality descriptor — source-agnostic."""
|
||||
|
||||
format: str # 'flac', 'mp3', 'aac', 'ogg', 'wav', 'unknown'
|
||||
bitrate: Optional[int] = None # kbps
|
||||
sample_rate: Optional[int] = None # Hz (e.g. 44100, 96000, 192000)
|
||||
bit_depth: Optional[int] = None # bits per sample (16, 24, 32)
|
||||
|
||||
def tier_score(self) -> float:
|
||||
"""Continuous score for ranking within a matched target bucket.
|
||||
Higher = better. Used as a tiebreaker after target-list matching.
|
||||
"""
|
||||
format_base: dict[str, float] = {
|
||||
'flac': 100.0,
|
||||
'wav': 95.0,
|
||||
'ogg': 70.0,
|
||||
'aac': 60.0,
|
||||
'mp3': 50.0,
|
||||
'wma': 30.0,
|
||||
}
|
||||
base = format_base.get(self.format.lower(), 10.0)
|
||||
|
||||
if self.format.lower() in ('flac', 'wav'):
|
||||
sr = self.sample_rate or 44100
|
||||
bd = self.bit_depth or 16
|
||||
# sample-rate contribution: 44.1 kHz = 0, 192 kHz = +20
|
||||
sr_score = min(sr / 192_000, 1.0) * 20
|
||||
# bit-depth contribution: 16-bit = 0, 24-bit = +10
|
||||
bd_score = max(bd - 16, 0) / 8 * 10
|
||||
return base + sr_score + bd_score
|
||||
else:
|
||||
br = self.bitrate or 0
|
||||
return base + min(br / 320, 1.0) * 10
|
||||
|
||||
def matches_target(self, target: QualityTarget) -> bool:
|
||||
"""True when this quality satisfies every constraint in *target*."""
|
||||
if target.format and target.format.lower() != self.format.lower():
|
||||
return False
|
||||
if target.min_bitrate and (self.bitrate or 0) < target.min_bitrate:
|
||||
return False
|
||||
if target.min_sample_rate:
|
||||
if self.sample_rate is not None:
|
||||
if self.sample_rate < target.min_sample_rate:
|
||||
return False
|
||||
else:
|
||||
# No sample-rate info — fall back to kbps heuristic for FLAC.
|
||||
# 16-bit/44.1 kHz ≈ 1411 kbps; 24-bit/96 kHz ≈ 4608 kbps.
|
||||
if self.format.lower() == 'flac' and self.bitrate:
|
||||
required_kbps = _sample_rate_to_min_kbps(target.min_sample_rate, target.bit_depth or 24)
|
||||
if self.bitrate < required_kbps:
|
||||
return False
|
||||
if target.bit_depth:
|
||||
if self.bit_depth is not None:
|
||||
if self.bit_depth < target.bit_depth:
|
||||
return False
|
||||
else:
|
||||
# No bit-depth info — use kbps heuristic.
|
||||
# 16-bit FLAC ≈ ≤1450 kbps; 24-bit starts ~1500 kbps.
|
||||
if self.format.lower() == 'flac' and target.bit_depth == 24:
|
||||
if self.bitrate and self.bitrate < 1450:
|
||||
return False
|
||||
return True
|
||||
|
||||
def label(self) -> str:
|
||||
"""Human-readable label, e.g. 'FLAC 24-bit/192kHz' or 'MP3 320kbps'."""
|
||||
fmt = self.format.upper()
|
||||
if self.format.lower() in ('flac', 'wav'):
|
||||
bd = f"{self.bit_depth}-bit/" if self.bit_depth else ""
|
||||
sr = f"{self.sample_rate // 1000}kHz" if self.sample_rate else ""
|
||||
detail = f" {bd}{sr}".rstrip()
|
||||
return f"{fmt}{detail}" if detail.strip() else fmt
|
||||
else:
|
||||
br = f" {self.bitrate}kbps" if self.bitrate else ""
|
||||
return f"{fmt}{br}"
|
||||
|
||||
@classmethod
|
||||
def from_slskd_file(cls, file_data: dict, extension: str) -> 'AudioQuality':
|
||||
"""Build from a raw slskd API file entry.
|
||||
|
||||
slskd exposes Soulseek protocol file attributes as:
|
||||
``{"attributes": [{"type": 4, "value": 96000}, {"type": 5, "value": 24}, ...]}``
|
||||
"""
|
||||
attrs = {a['type']: a['value'] for a in file_data.get('attributes', [])}
|
||||
return cls(
|
||||
format=extension.lower().lstrip('.'),
|
||||
bitrate=file_data.get('bitRate') or attrs.get(0),
|
||||
sample_rate=attrs.get(4),
|
||||
bit_depth=attrs.get(5),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_tier(cls, fmt: str, bitrate: int, sample_rate: Optional[int] = None, bit_depth: Optional[int] = None) -> 'AudioQuality':
|
||||
"""Build from a hardcoded quality tier (Tidal, Deezer, Qobuz)."""
|
||||
return cls(format=fmt, bitrate=bitrate, sample_rate=sample_rate, bit_depth=bit_depth)
|
||||
|
||||
@classmethod
|
||||
def from_extension_and_bitrate(cls, extension: str, bitrate: Optional[int]) -> 'AudioQuality':
|
||||
"""Minimal constructor when only format + bitrate are known (torrent, YouTube)."""
|
||||
return cls(format=extension.lower().lstrip('.'), bitrate=bitrate)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QualityTarget:
|
||||
"""One ranked entry in the user's quality priority list."""
|
||||
|
||||
label: str = ""
|
||||
format: Optional[str] = None # 'flac', 'mp3', 'aac', …
|
||||
bit_depth: Optional[int] = None # 16, 24
|
||||
min_sample_rate: Optional[int] = None # Hz
|
||||
min_bitrate: Optional[int] = None # kbps (lossy)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {k: v for k, v in self.__dict__.items() if v not in (None, "")}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> 'QualityTarget':
|
||||
known = {f.name for f in cls.__dataclass_fields__.values()} # type: ignore[attr-defined]
|
||||
return cls(**{k: v for k, v in d.items() if k in known})
|
||||
|
||||
|
||||
# ── Default priority list ──────────────────────────────────────────────────────
|
||||
|
||||
DEFAULT_RANKED_TARGETS: List[QualityTarget] = [
|
||||
QualityTarget(label='FLAC 24-bit/192kHz', format='flac', bit_depth=24, min_sample_rate=192_000),
|
||||
QualityTarget(label='FLAC 24-bit/96kHz', format='flac', bit_depth=24, min_sample_rate=96_000),
|
||||
QualityTarget(label='FLAC 24-bit/48kHz', format='flac', bit_depth=24, min_sample_rate=48_000),
|
||||
QualityTarget(label='FLAC 24-bit/44.1kHz',format='flac', bit_depth=24, min_sample_rate=44_100),
|
||||
QualityTarget(label='FLAC 16-bit', format='flac', bit_depth=16),
|
||||
QualityTarget(label='MP3 320kbps', format='mp3', min_bitrate=320),
|
||||
QualityTarget(label='MP3 256kbps', format='mp3', min_bitrate=256),
|
||||
QualityTarget(label='MP3 192kbps', format='mp3', min_bitrate=192),
|
||||
]
|
||||
|
||||
|
||||
# ── Ranking helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def rank_candidate(aq: AudioQuality, targets: List[QualityTarget]) -> Tuple[int, float]:
|
||||
"""Return *(target_index, tier_score)* for sorting.
|
||||
|
||||
Lower ``target_index`` → higher priority match.
|
||||
Candidates that satisfy no target get ``index = len(targets)``
|
||||
(they sort last but are not discarded — the caller decides that).
|
||||
"""
|
||||
for i, target in enumerate(targets):
|
||||
if aq.matches_target(target):
|
||||
return (i, aq.tier_score())
|
||||
return (len(targets), aq.tier_score())
|
||||
|
||||
|
||||
def filter_and_rank(
|
||||
candidates: list,
|
||||
targets: List[QualityTarget],
|
||||
*,
|
||||
fallback_enabled: bool = True,
|
||||
) -> list:
|
||||
"""Sort *candidates* (any objects with an ``audio_quality`` attribute)
|
||||
by quality priority.
|
||||
|
||||
Returns the subset that matched the *highest-priority* satisfied target,
|
||||
sorted by ``tier_score`` descending within that group.
|
||||
Falls back to all candidates sorted by score when ``fallback_enabled``
|
||||
and nothing matches, or when targets list is empty.
|
||||
"""
|
||||
if not targets:
|
||||
candidates_copy = list(candidates)
|
||||
candidates_copy.sort(key=lambda c: c.audio_quality.tier_score(), reverse=True)
|
||||
return candidates_copy
|
||||
|
||||
scored = [(rank_candidate(c.audio_quality, targets), c) for c in candidates]
|
||||
|
||||
# Best target index that any candidate reached
|
||||
best_idx = min((s[0][0] for s in scored), default=len(targets))
|
||||
|
||||
if best_idx < len(targets):
|
||||
winners = [c for (idx, _), c in scored if idx == best_idx]
|
||||
winners.sort(key=lambda c: c.audio_quality.tier_score(), reverse=True)
|
||||
return winners
|
||||
|
||||
if fallback_enabled:
|
||||
all_sorted = list(candidates)
|
||||
all_sorted.sort(key=lambda c: c.audio_quality.tier_score(), reverse=True)
|
||||
return all_sorted
|
||||
|
||||
return []
|
||||
|
||||
|
||||
# ── Migration helper ───────────────────────────────────────────────────────────
|
||||
|
||||
def v2_qualities_to_ranked_targets(qualities: dict) -> List[dict]:
|
||||
"""Convert old v2 ``qualities`` dict to a ranked-targets list.
|
||||
|
||||
Preserves the user's existing priority order while upgrading to the
|
||||
richer target format.
|
||||
"""
|
||||
_FORMAT_MAP = {
|
||||
'flac': {'format': 'flac', 'bit_depth': None},
|
||||
'mp3_320': {'format': 'mp3', 'min_bitrate': 320},
|
||||
'mp3_256': {'format': 'mp3', 'min_bitrate': 256},
|
||||
'mp3_192': {'format': 'mp3', 'min_bitrate': 192},
|
||||
}
|
||||
enabled = [
|
||||
(cfg.get('priority', 999), name, cfg)
|
||||
for name, cfg in qualities.items()
|
||||
if cfg.get('enabled', False)
|
||||
]
|
||||
enabled.sort()
|
||||
targets = []
|
||||
for _, name, cfg in enabled:
|
||||
base = _FORMAT_MAP.get(name, {}).copy()
|
||||
if not base:
|
||||
continue
|
||||
if name == 'flac':
|
||||
bd = cfg.get('bit_depth', 'any')
|
||||
if bd == '24':
|
||||
base['bit_depth'] = 24
|
||||
base['label'] = 'FLAC 24-bit'
|
||||
elif bd == '16':
|
||||
base['bit_depth'] = 16
|
||||
base['label'] = 'FLAC 16-bit'
|
||||
else:
|
||||
base['label'] = 'FLAC (any)'
|
||||
else:
|
||||
base['label'] = name.upper().replace('_', ' ')
|
||||
targets.append(base)
|
||||
return targets
|
||||
|
||||
|
||||
# ── Internal helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
def _sample_rate_to_min_kbps(sample_rate: int, bit_depth: int) -> int:
|
||||
"""Approximate minimum kbps for a lossless file at the given spec.
|
||||
Used as heuristic when actual sample-rate metadata is absent.
|
||||
"""
|
||||
# kbps = sample_rate * channels * bit_depth / 1000 * compression_ratio
|
||||
# Assume stereo (2 ch) and ~0.6 FLAC compression ratio
|
||||
raw_kbps = sample_rate * 2 * bit_depth / 1000
|
||||
return int(raw_kbps * 0.55) # conservative compressed estimate
|
||||
|
|
@ -24,6 +24,7 @@ from core.download_plugins.album_bundle import (
|
|||
get_poll_timeout,
|
||||
)
|
||||
from core.download_plugins.base import DownloadSourcePlugin
|
||||
from core.quality.model import QualityTarget, filter_and_rank, v2_qualities_to_ranked_targets
|
||||
from utils.async_helpers import run_async
|
||||
|
||||
logger = get_logger("soulseek_client")
|
||||
|
|
@ -433,16 +434,19 @@ class SoulseekClient(DownloadSourcePlugin):
|
|||
raw_duration = file_data.get('length')
|
||||
duration_ms = raw_duration * 1000 if raw_duration else None
|
||||
|
||||
slskd_attrs = {a['type']: a['value'] for a in file_data.get('attributes', [])}
|
||||
track = TrackResult(
|
||||
username=username,
|
||||
filename=filename,
|
||||
size=size,
|
||||
bitrate=file_data.get('bitRate'),
|
||||
bitrate=file_data.get('bitRate') or slskd_attrs.get(0),
|
||||
duration=duration_ms,
|
||||
quality=quality,
|
||||
free_upload_slots=response_data.get('freeUploadSlots', 0),
|
||||
upload_speed=response_data.get('uploadSpeed', 0),
|
||||
queue_length=response_data.get('queueLength', 0)
|
||||
queue_length=response_data.get('queueLength', 0),
|
||||
sample_rate=slskd_attrs.get(4),
|
||||
bit_depth=slskd_attrs.get(5),
|
||||
)
|
||||
|
||||
all_tracks.append(track)
|
||||
|
|
@ -1150,10 +1154,14 @@ class SoulseekClient(DownloadSourcePlugin):
|
|||
quality = ext.lstrip('.') if ext.lstrip('.') in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown'
|
||||
raw_duration = file_data.get('length')
|
||||
duration_ms = raw_duration * 1000 if raw_duration else None
|
||||
slskd_attrs = {a['type']: a['value'] for a in file_data.get('attributes', [])}
|
||||
results.append(TrackResult(
|
||||
username=username, filename=filename, size=file_data.get('size', 0),
|
||||
bitrate=file_data.get('bitRate'), duration=duration_ms, quality=quality,
|
||||
free_upload_slots=free_slots, upload_speed=upload_speed, queue_length=queue_length
|
||||
bitrate=file_data.get('bitRate') or slskd_attrs.get(0),
|
||||
duration=duration_ms, quality=quality,
|
||||
free_upload_slots=free_slots, upload_speed=upload_speed, queue_length=queue_length,
|
||||
sample_rate=slskd_attrs.get(4),
|
||||
bit_depth=slskd_attrs.get(5),
|
||||
))
|
||||
return results
|
||||
|
||||
|
|
@ -2002,177 +2010,46 @@ class SoulseekClient(DownloadSourcePlugin):
|
|||
return kept
|
||||
|
||||
def filter_results_by_quality_preference(self, results: List[TrackResult]) -> List[TrackResult]:
|
||||
"""
|
||||
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 effective bitrate.
|
||||
"""Filter and rank candidates using the global quality target list.
|
||||
|
||||
Issue #652: also drops candidates whose `(username, filename)`
|
||||
matches a previously-quarantined download. Without this pre-filter
|
||||
the auto-wishlist processor's ranking is deterministic — the same
|
||||
`(uploader, file)` keeps winning the quality picker, downloading,
|
||||
failing AcoustID, quarantining, and re-queueing in an infinite
|
||||
loop. Users wake up to hundreds of duplicate `.quarantined` files
|
||||
for the same source URL.
|
||||
Replaces the old bucket+heuristic approach with ``core.quality.model``
|
||||
so every download source shares the same ranking logic.
|
||||
|
||||
Issue #652: also drops candidates whose ``(username, filename)``
|
||||
matches a previously-quarantined download to break infinite retry loops.
|
||||
"""
|
||||
from database.music_database import MusicDatabase
|
||||
|
||||
if not results:
|
||||
return []
|
||||
|
||||
# Drop sources already quarantined — bypass the quality picker
|
||||
# entirely so the same bad upload doesn't get re-selected on the
|
||||
# next wishlist cycle. Filesystem read is bounded (~few hundred
|
||||
# sidecars in practical use × <1ms each).
|
||||
results = self._drop_quarantined_sources(results)
|
||||
if not results:
|
||||
return []
|
||||
|
||||
# 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")
|
||||
# Build ranked target list — v3 profiles carry it directly;
|
||||
# v2 profiles are converted on the fly (no DB write needed here).
|
||||
raw_targets = profile.get('ranked_targets')
|
||||
if not raw_targets and 'qualities' in profile:
|
||||
raw_targets = v2_qualities_to_ranked_targets(profile['qualities'])
|
||||
|
||||
# Categorize candidates by quality with bitrate density constraints
|
||||
quality_buckets = {
|
||||
'flac': [],
|
||||
'mp3_320': [],
|
||||
'mp3_256': [],
|
||||
'mp3_192': [],
|
||||
'other': []
|
||||
}
|
||||
targets = [QualityTarget.from_dict(t) for t in (raw_targets or [])]
|
||||
fallback_enabled = profile.get('fallback_enabled', True)
|
||||
|
||||
# Track all candidates that pass checks (for fallback)
|
||||
density_filtered_all = []
|
||||
logger.debug(
|
||||
"Quality Filter: profile='%s', %d targets, %d candidates",
|
||||
profile.get('preset', 'custom'), len(targets), len(results),
|
||||
)
|
||||
|
||||
for candidate in results:
|
||||
if not candidate.quality:
|
||||
quality_buckets['other'].append(candidate)
|
||||
continue
|
||||
ranked = filter_and_rank(results, targets, fallback_enabled=fallback_enabled)
|
||||
|
||||
track_format = candidate.quality.lower()
|
||||
track_bitrate = candidate.bitrate or 0
|
||||
|
||||
# Determine quality key
|
||||
if track_format == 'flac':
|
||||
quality_key = 'flac'
|
||||
elif track_format == 'mp3':
|
||||
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
|
||||
else:
|
||||
quality_buckets['other'].append(candidate)
|
||||
continue
|
||||
|
||||
quality_config = profile['qualities'].get(quality_key, {})
|
||||
min_kbps = quality_config.get('min_kbps', 0)
|
||||
max_kbps = quality_config.get('max_kbps', 99999)
|
||||
|
||||
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)
|
||||
density_filtered_all.append(candidate)
|
||||
else:
|
||||
logger.debug(f"Quality Filter: {quality_key} rejected - {effective_kbps:.0f} kbps outside {min_kbps}-{max_kbps} kbps range")
|
||||
else:
|
||||
# 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: effective bitrate first (prefer highest audio quality),
|
||||
# then peer quality score as tiebreaker (prefer fastest peer at same quality)
|
||||
for bucket in quality_buckets.values():
|
||||
bucket.sort(key=lambda x: (self._calculate_effective_kbps(x.size, x.duration) or 0, x.quality_score), reverse=True)
|
||||
|
||||
# Enforce FLAC bit depth preference from quality profile
|
||||
flac_config = profile['qualities'].get('flac', {})
|
||||
bit_depth_pref = flac_config.get('bit_depth', 'any')
|
||||
bit_depth_fallback = flac_config.get('bit_depth_fallback', True)
|
||||
|
||||
if bit_depth_pref != 'any' and quality_buckets['flac']:
|
||||
# 16-bit/44.1kHz FLAC theoretical max is 1411 kbps; 24-bit starts at ~2116 kbps
|
||||
# Real-world compressed: 16-bit = 800-1400 kbps, 24-bit = 1500+ kbps
|
||||
DEPTH_THRESHOLD = 1450
|
||||
|
||||
if bit_depth_pref == '24':
|
||||
hi_res = [c for c in quality_buckets['flac']
|
||||
if (self._calculate_effective_kbps(c.size, c.duration) or 0) > DEPTH_THRESHOLD]
|
||||
if hi_res:
|
||||
logger.info(f"Quality Filter: Bit depth 24-bit preference — {len(hi_res)}/{len(quality_buckets['flac'])} FLAC candidates are hi-res")
|
||||
quality_buckets['flac'] = hi_res
|
||||
elif not bit_depth_fallback:
|
||||
logger.info("Quality Filter: No 24-bit FLAC found and fallback disabled — rejecting all FLAC")
|
||||
quality_buckets['flac'] = []
|
||||
else:
|
||||
logger.info("Quality Filter: No 24-bit FLAC found — falling back to 16-bit")
|
||||
|
||||
elif bit_depth_pref == '16':
|
||||
lo_res = [c for c in quality_buckets['flac']
|
||||
if (self._calculate_effective_kbps(c.size, c.duration) or 0) <= DEPTH_THRESHOLD]
|
||||
if lo_res:
|
||||
logger.info(f"Quality Filter: Bit depth 16-bit preference — {len(lo_res)}/{len(quality_buckets['flac'])} FLAC candidates are standard")
|
||||
quality_buckets['flac'] = lo_res
|
||||
elif not bit_depth_fallback:
|
||||
logger.info("Quality Filter: No 16-bit FLAC found and fallback disabled — rejecting all FLAC")
|
||||
quality_buckets['flac'] = []
|
||||
else:
|
||||
logger.info("Quality Filter: No 16-bit FLAC found — falling back to 24-bit")
|
||||
|
||||
# Debug logging
|
||||
for quality, bucket in quality_buckets.items():
|
||||
if bucket:
|
||||
logger.debug(f"Quality Filter: Found {len(bucket)} '{quality}' candidates (after bitrate + bit depth 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("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:
|
||||
logger.warning("Quality Filter: All candidates failed bitrate checks, returning empty (respecting constraints)")
|
||||
return []
|
||||
if ranked:
|
||||
best_label = ranked[0].audio_quality.label()
|
||||
logger.info("Quality Filter: returning %d candidate(s), best=%s", len(ranked), best_label)
|
||||
else:
|
||||
logger.warning("Quality Filter: No enabled qualities matched and fallback is disabled, returning empty")
|
||||
return []
|
||||
|
||||
logger.warning("Quality Filter: no candidates passed quality constraints")
|
||||
|
||||
return ranked
|
||||
|
||||
async def get_session_info(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get slskd session information including version"""
|
||||
if not self.base_url:
|
||||
|
|
|
|||
|
|
@ -8580,7 +8580,7 @@ class MusicDatabase:
|
|||
# Quality profile management methods
|
||||
|
||||
def get_quality_profile(self) -> dict:
|
||||
"""Get the quality profile configuration, returns default if not set"""
|
||||
"""Get the quality profile configuration, returns default if not set."""
|
||||
import json
|
||||
|
||||
profile_json = self.get_preference('quality_profile')
|
||||
|
|
@ -8588,49 +8588,53 @@ class MusicDatabase:
|
|||
if profile_json:
|
||||
try:
|
||||
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)")
|
||||
version = profile.get('version', 1)
|
||||
if version < 2:
|
||||
logger.info("Migrating quality profile v1 → v3")
|
||||
return self._get_default_quality_profile()
|
||||
if version == 2:
|
||||
logger.info("Migrating quality profile v2 → v3 (adding ranked_targets)")
|
||||
return self._migrate_v2_to_v3(profile)
|
||||
return profile
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Failed to parse quality profile JSON, returning default")
|
||||
|
||||
return self._get_default_quality_profile()
|
||||
|
||||
def _migrate_v2_to_v3(self, profile: dict) -> dict:
|
||||
"""Add ranked_targets to a v2 profile without losing its qualities dict."""
|
||||
from core.quality.model import v2_qualities_to_ranked_targets
|
||||
profile = dict(profile)
|
||||
profile['version'] = 3
|
||||
if 'ranked_targets' not in profile:
|
||||
profile['ranked_targets'] = v2_qualities_to_ranked_targets(
|
||||
profile.get('qualities', {})
|
||||
)
|
||||
return profile
|
||||
|
||||
def _get_default_quality_profile(self) -> dict:
|
||||
"""Return the default v2 quality profile (balanced preset)"""
|
||||
"""Return the default v3 quality profile (balanced preset)."""
|
||||
return {
|
||||
"version": 2,
|
||||
"version": 3,
|
||||
"preset": "balanced",
|
||||
"fallback_enabled": True,
|
||||
"ranked_targets": [
|
||||
{"label": "FLAC 24-bit/192kHz", "format": "flac", "bit_depth": 24, "min_sample_rate": 192000},
|
||||
{"label": "FLAC 24-bit/96kHz", "format": "flac", "bit_depth": 24, "min_sample_rate": 96000},
|
||||
{"label": "FLAC 24-bit/48kHz", "format": "flac", "bit_depth": 24, "min_sample_rate": 48000},
|
||||
{"label": "FLAC 24-bit/44.1kHz","format": "flac", "bit_depth": 24, "min_sample_rate": 44100},
|
||||
{"label": "FLAC 16-bit", "format": "flac", "bit_depth": 16},
|
||||
{"label": "MP3 320kbps", "format": "mp3", "min_bitrate": 320},
|
||||
{"label": "MP3 256kbps", "format": "mp3", "min_bitrate": 256},
|
||||
{"label": "MP3 192kbps", "format": "mp3", "min_bitrate": 192},
|
||||
],
|
||||
# Keep qualities dict for backwards compat with any old code paths still reading it
|
||||
"qualities": {
|
||||
"flac": {
|
||||
"enabled": True,
|
||||
"min_kbps": 500,
|
||||
"max_kbps": 10000,
|
||||
"priority": 1,
|
||||
"bit_depth": "any"
|
||||
},
|
||||
"mp3_320": {
|
||||
"enabled": True,
|
||||
"min_kbps": 280,
|
||||
"max_kbps": 500,
|
||||
"priority": 2
|
||||
},
|
||||
"mp3_256": {
|
||||
"enabled": True,
|
||||
"min_kbps": 200,
|
||||
"max_kbps": 400,
|
||||
"priority": 3
|
||||
},
|
||||
"mp3_192": {
|
||||
"enabled": False,
|
||||
"min_kbps": 150,
|
||||
"max_kbps": 300,
|
||||
"priority": 4
|
||||
}
|
||||
"flac": {"enabled": True, "min_kbps": 500, "max_kbps": 10000, "priority": 1, "bit_depth": "any"},
|
||||
"mp3_320": {"enabled": True, "min_kbps": 280, "max_kbps": 500, "priority": 2},
|
||||
"mp3_256": {"enabled": True, "min_kbps": 200, "max_kbps": 400, "priority": 3},
|
||||
"mp3_192": {"enabled": False, "min_kbps": 150, "max_kbps": 300, "priority": 4},
|
||||
},
|
||||
"fallback_enabled": True
|
||||
}
|
||||
|
||||
def set_quality_profile(self, profile: dict) -> bool:
|
||||
|
|
@ -8647,104 +8651,32 @@ class MusicDatabase:
|
|||
return False
|
||||
|
||||
def get_quality_preset(self, preset_name: str) -> dict:
|
||||
"""Get a predefined quality preset"""
|
||||
"""Get a predefined quality preset (v3 format with ranked_targets)."""
|
||||
_FLAC_HI_RES_TARGETS = [
|
||||
{"label": "FLAC 24-bit/192kHz", "format": "flac", "bit_depth": 24, "min_sample_rate": 192000},
|
||||
{"label": "FLAC 24-bit/96kHz", "format": "flac", "bit_depth": 24, "min_sample_rate": 96000},
|
||||
{"label": "FLAC 24-bit/48kHz", "format": "flac", "bit_depth": 24, "min_sample_rate": 48000},
|
||||
{"label": "FLAC 24-bit/44.1kHz","format": "flac", "bit_depth": 24, "min_sample_rate": 44100},
|
||||
{"label": "FLAC 16-bit", "format": "flac", "bit_depth": 16},
|
||||
]
|
||||
_MP3_TARGETS = [
|
||||
{"label": "MP3 320kbps", "format": "mp3", "min_bitrate": 320},
|
||||
{"label": "MP3 256kbps", "format": "mp3", "min_bitrate": 256},
|
||||
{"label": "MP3 192kbps", "format": "mp3", "min_bitrate": 192},
|
||||
]
|
||||
presets = {
|
||||
"audiophile": {
|
||||
"version": 2,
|
||||
"preset": "audiophile",
|
||||
"qualities": {
|
||||
"flac": {
|
||||
"enabled": True,
|
||||
"min_kbps": 500,
|
||||
"max_kbps": 10000,
|
||||
"priority": 1,
|
||||
"bit_depth": "any"
|
||||
},
|
||||
"mp3_320": {
|
||||
"enabled": False,
|
||||
"min_kbps": 280,
|
||||
"max_kbps": 500,
|
||||
"priority": 2
|
||||
},
|
||||
"mp3_256": {
|
||||
"enabled": False,
|
||||
"min_kbps": 200,
|
||||
"max_kbps": 400,
|
||||
"priority": 3
|
||||
},
|
||||
"mp3_192": {
|
||||
"enabled": False,
|
||||
"min_kbps": 150,
|
||||
"max_kbps": 300,
|
||||
"priority": 4
|
||||
}
|
||||
},
|
||||
"fallback_enabled": False
|
||||
"version": 3, "preset": "audiophile", "fallback_enabled": False,
|
||||
"ranked_targets": _FLAC_HI_RES_TARGETS,
|
||||
},
|
||||
"balanced": {
|
||||
"version": 2,
|
||||
"preset": "balanced",
|
||||
"qualities": {
|
||||
"flac": {
|
||||
"enabled": True,
|
||||
"min_kbps": 500,
|
||||
"max_kbps": 10000,
|
||||
"priority": 1,
|
||||
"bit_depth": "any"
|
||||
},
|
||||
"mp3_320": {
|
||||
"enabled": True,
|
||||
"min_kbps": 280,
|
||||
"max_kbps": 500,
|
||||
"priority": 2
|
||||
},
|
||||
"mp3_256": {
|
||||
"enabled": True,
|
||||
"min_kbps": 200,
|
||||
"max_kbps": 400,
|
||||
"priority": 3
|
||||
},
|
||||
"mp3_192": {
|
||||
"enabled": False,
|
||||
"min_kbps": 150,
|
||||
"max_kbps": 300,
|
||||
"priority": 4
|
||||
}
|
||||
},
|
||||
"fallback_enabled": True
|
||||
"version": 3, "preset": "balanced", "fallback_enabled": True,
|
||||
"ranked_targets": _FLAC_HI_RES_TARGETS + _MP3_TARGETS,
|
||||
},
|
||||
"space_saver": {
|
||||
"version": 2,
|
||||
"preset": "space_saver",
|
||||
"qualities": {
|
||||
"flac": {
|
||||
"enabled": False,
|
||||
"min_kbps": 500,
|
||||
"max_kbps": 10000,
|
||||
"priority": 4,
|
||||
"bit_depth": "any"
|
||||
},
|
||||
"mp3_320": {
|
||||
"enabled": True,
|
||||
"min_kbps": 280,
|
||||
"max_kbps": 500,
|
||||
"priority": 1
|
||||
},
|
||||
"mp3_256": {
|
||||
"enabled": True,
|
||||
"min_kbps": 200,
|
||||
"max_kbps": 400,
|
||||
"priority": 2
|
||||
},
|
||||
"mp3_192": {
|
||||
"enabled": True,
|
||||
"min_kbps": 150,
|
||||
"max_kbps": 300,
|
||||
"priority": 3
|
||||
}
|
||||
},
|
||||
"fallback_enabled": True
|
||||
}
|
||||
"version": 3, "preset": "space_saver", "fallback_enabled": True,
|
||||
"ranked_targets": _MP3_TARGETS,
|
||||
},
|
||||
}
|
||||
|
||||
return presets.get(preset_name, presets["balanced"])
|
||||
|
|
|
|||
Loading…
Reference in a new issue