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:
nick2000713 2026-06-13 22:22:08 +02:00
parent 78f47f04d7
commit 2c91af0062
8 changed files with 531 additions and 315 deletions

View file

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

View file

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

View file

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

View file

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

261
core/quality/model.py Normal file
View 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

View file

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

View file

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