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 __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from core.imports.filename import parse_filename_metadata from core.imports.filename import parse_filename_metadata
from core.quality.model import AudioQuality
@dataclass @dataclass
@ -32,6 +33,20 @@ class SearchResult:
upload_speed: int upload_speed: int
queue_length: int queue_length: int
result_type: str = "track" # "track" or "album" 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 @property
def quality_score(self) -> float: def quality_score(self) -> float:
@ -127,6 +142,19 @@ class AlbumResult:
queue_length: int = 0 queue_length: int = 0
result_type: str = "album" 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 @property
def quality_score(self) -> float: def quality_score(self) -> float:
"""Calculate album quality score based on dominant quality and track count""" """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 "" 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): def get_quality_tier_from_extension(file_path):
"""Classify a file extension into a quality tier.""" """Classify a file extension into a quality tier."""
if not file_path: 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]: 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.""" """Legacy wrapper — delegates to check_quality_target.
if not context.get("_audio_quality", "").startswith("FLAC"):
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 return None
quality_profile = MusicDatabase().get_quality_profile() profile = MusicDatabase().get_quality_profile()
flac_config = quality_profile.get("qualities", {}).get("flac", {}) raw_targets = profile.get("ranked_targets")
flac_pref = flac_config.get("bit_depth", "any") if not raw_targets and "qualities" in profile:
if flac_pref == "any": raw_targets = v2_qualities_to_ranked_targets(profile["qualities"])
if not raw_targets:
return None return None
actual_bits = context["_audio_quality"].replace("FLAC ", "").replace("bit", "") targets = [QualityTarget.from_dict(t) for t in raw_targets]
if actual_bits == flac_pref: fallback_enabled = profile.get("fallback_enabled", True)
return None
flac_fallback = flac_config.get("bit_depth_fallback", True)
downsample_enabled = _get_config_manager().get("lossy_copy.downsample_hires", False) 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_info = context.get("track_info", {})
track_name = track_info.get("name", os.path.basename(file_path)) track_name = track_info.get("name", os.path.basename(file_path))
actual_label = aq.label()
if flac_fallback or downsample_enabled: if matched:
if downsample_enabled: logger.info("[QualityGuard] %s matched target '%s': %s", track_name, targets[target_idx].label, actual_label)
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)
return None 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.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.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 ( from core.imports.quarantine import (
approve_quarantine_entry, approve_quarantine_entry,
entry_id_from_quarantined_filename, 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')}" f"{context.get('_acoustid_failure_msg', 'fingerprint mismatch')}"
) )
if context.get('_bitdepth_rejected'): if context.get('_bitdepth_rejected'):
return "rejected by bit-depth filter" return "rejected by quality filter"
if context.get('_race_guard_failed'): if context.get('_race_guard_failed'):
return "source file disappeared before import completed" return "source file disappeared before import completed"
return None return None
@ -581,10 +581,11 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
if context['_audio_quality']: if context['_audio_quality']:
logger.info(f"Audio quality detected: {context['_audio_quality']}") logger.info(f"Audio quality detected: {context['_audio_quality']}")
_skip_bit_depth = _should_skip_quarantine_check(context, 'bit_depth') _skip_quality = _should_skip_quarantine_check(context, 'bit_depth') or \
rejection_reason = None if _skip_bit_depth else check_flac_bit_depth(file_path, context) _should_skip_quarantine_check(context, 'quality')
if _skip_bit_depth: rejection_reason = None if _skip_quality else check_quality_target(file_path, context)
logger.info(f"[BitDepth] Skipped (user approval) for {_basename}") if _skip_quality:
logger.info(f"[QualityGuard] Skipped (user approval) for {_basename}")
if rejection_reason: if rejection_reason:
try: try:
quarantine_path = move_to_quarantine( quarantine_path = move_to_quarantine(
@ -592,10 +593,10 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
context, context,
rejection_reason, rejection_reason,
automation_engine, automation_engine,
trigger='bit_depth', trigger='quality',
) )
_mark_task_quarantined(context, quarantine_path) _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: except Exception as quarantine_error:
logger.error(f"Quarantine failed ({quarantine_error}), deleting file: {file_path}") logger.error(f"Quarantine failed ({quarantine_error}), deleting file: {file_path}")
try: try:
@ -604,17 +605,25 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
logger.debug("delete quarantine fallback: %s", e) logger.debug("delete quarantine fallback: %s", e)
context['_bitdepth_rejected'] = True 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') task_id = context.get('task_id')
batch_id = context.get('batch_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: if task_id:
with tasks_lock: with tasks_lock:
if task_id in download_tasks: if task_id in download_tasks:
download_tasks[task_id]['status'] = 'failed' 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: if task_id and batch_id:
_notify_download_completed(batch_id, task_id, success=False) _notify_download_completed(batch_id, task_id, success=False)
return 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, get_poll_timeout,
) )
from core.download_plugins.base import DownloadSourcePlugin 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 from utils.async_helpers import run_async
logger = get_logger("soulseek_client") logger = get_logger("soulseek_client")
@ -433,16 +434,19 @@ class SoulseekClient(DownloadSourcePlugin):
raw_duration = file_data.get('length') raw_duration = file_data.get('length')
duration_ms = raw_duration * 1000 if raw_duration else None 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( track = TrackResult(
username=username, username=username,
filename=filename, filename=filename,
size=size, size=size,
bitrate=file_data.get('bitRate'), bitrate=file_data.get('bitRate') or slskd_attrs.get(0),
duration=duration_ms, duration=duration_ms,
quality=quality, quality=quality,
free_upload_slots=response_data.get('freeUploadSlots', 0), free_upload_slots=response_data.get('freeUploadSlots', 0),
upload_speed=response_data.get('uploadSpeed', 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) 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' quality = ext.lstrip('.') if ext.lstrip('.') in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown'
raw_duration = file_data.get('length') raw_duration = file_data.get('length')
duration_ms = raw_duration * 1000 if raw_duration else None 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( results.append(TrackResult(
username=username, filename=filename, size=file_data.get('size', 0), username=username, filename=filename, size=file_data.get('size', 0),
bitrate=file_data.get('bitRate'), duration=duration_ms, quality=quality, bitrate=file_data.get('bitRate') or slskd_attrs.get(0),
free_upload_slots=free_slots, upload_speed=upload_speed, queue_length=queue_length 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 return results
@ -2002,177 +2010,46 @@ class SoulseekClient(DownloadSourcePlugin):
return kept return kept
def filter_results_by_quality_preference(self, results: List[TrackResult]) -> List[TrackResult]: def filter_results_by_quality_preference(self, results: List[TrackResult]) -> List[TrackResult]:
""" """Filter and rank candidates using the global quality target list.
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.
Issue #652: also drops candidates whose `(username, filename)` Replaces the old bucket+heuristic approach with ``core.quality.model``
matches a previously-quarantined download. Without this pre-filter so every download source shares the same ranking logic.
the auto-wishlist processor's ranking is deterministic — the same
`(uploader, file)` keeps winning the quality picker, downloading, Issue #652: also drops candidates whose ``(username, filename)``
failing AcoustID, quarantining, and re-queueing in an infinite matches a previously-quarantined download to break infinite retry loops.
loop. Users wake up to hundreds of duplicate `.quarantined` files
for the same source URL.
""" """
from database.music_database import MusicDatabase from database.music_database import MusicDatabase
if not results: if not results:
return [] 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() db = MusicDatabase()
profile = db.get_quality_profile() 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 targets = [QualityTarget.from_dict(t) for t in (raw_targets or [])]
quality_buckets = { fallback_enabled = profile.get('fallback_enabled', True)
'flac': [],
'mp3_320': [],
'mp3_256': [],
'mp3_192': [],
'other': []
}
# Track all candidates that pass checks (for fallback) logger.debug(
density_filtered_all = [] "Quality Filter: profile='%s', %d targets, %d candidates",
profile.get('preset', 'custom'), len(targets), len(results),
)
for candidate in results: ranked = filter_and_rank(results, targets, fallback_enabled=fallback_enabled)
if not candidate.quality:
quality_buckets['other'].append(candidate)
continue
track_format = candidate.quality.lower() if ranked:
track_bitrate = candidate.bitrate or 0 best_label = ranked[0].audio_quality.label()
logger.info("Quality Filter: returning %d candidate(s), best=%s", len(ranked), best_label)
# 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 []
else: else:
logger.warning("Quality Filter: No enabled qualities matched and fallback is disabled, returning empty") logger.warning("Quality Filter: no candidates passed quality constraints")
return []
return ranked
async def get_session_info(self) -> Optional[Dict[str, Any]]: async def get_session_info(self) -> Optional[Dict[str, Any]]:
"""Get slskd session information including version""" """Get slskd session information including version"""
if not self.base_url: if not self.base_url:

View file

@ -8580,7 +8580,7 @@ class MusicDatabase:
# Quality profile management methods # Quality profile management methods
def get_quality_profile(self) -> dict: 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 import json
profile_json = self.get_preference('quality_profile') profile_json = self.get_preference('quality_profile')
@ -8588,49 +8588,53 @@ class MusicDatabase:
if profile_json: if profile_json:
try: try:
profile = json.loads(profile_json) profile = json.loads(profile_json)
# Migrate v1 profiles (min_mb/max_mb) to v2 (min_kbps/max_kbps) version = profile.get('version', 1)
if profile.get('version', 1) < 2: if version < 2:
logger.info("Migrating quality profile from v1 (file size) to v2 (bitrate density)") logger.info("Migrating quality profile v1 → v3")
return self._get_default_quality_profile() 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 return profile
except json.JSONDecodeError: except json.JSONDecodeError:
logger.error("Failed to parse quality profile JSON, returning default") logger.error("Failed to parse quality profile JSON, returning default")
return self._get_default_quality_profile() 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: def _get_default_quality_profile(self) -> dict:
"""Return the default v2 quality profile (balanced preset)""" """Return the default v3 quality profile (balanced preset)."""
return { return {
"version": 2, "version": 3,
"preset": "balanced", "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": { "qualities": {
"flac": { "flac": {"enabled": True, "min_kbps": 500, "max_kbps": 10000, "priority": 1, "bit_depth": "any"},
"enabled": True, "mp3_320": {"enabled": True, "min_kbps": 280, "max_kbps": 500, "priority": 2},
"min_kbps": 500, "mp3_256": {"enabled": True, "min_kbps": 200, "max_kbps": 400, "priority": 3},
"max_kbps": 10000, "mp3_192": {"enabled": False, "min_kbps": 150, "max_kbps": 300, "priority": 4},
"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: def set_quality_profile(self, profile: dict) -> bool:
@ -8647,104 +8651,32 @@ class MusicDatabase:
return False return False
def get_quality_preset(self, preset_name: str) -> dict: 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 = { presets = {
"audiophile": { "audiophile": {
"version": 2, "version": 3, "preset": "audiophile", "fallback_enabled": False,
"preset": "audiophile", "ranked_targets": _FLAC_HI_RES_TARGETS,
"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
}, },
"balanced": { "balanced": {
"version": 2, "version": 3, "preset": "balanced", "fallback_enabled": True,
"preset": "balanced", "ranked_targets": _FLAC_HI_RES_TARGETS + _MP3_TARGETS,
"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
}, },
"space_saver": { "space_saver": {
"version": 2, "version": 3, "preset": "space_saver", "fallback_enabled": True,
"preset": "space_saver", "ranked_targets": _MP3_TARGETS,
"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
}
} }
return presets.get(preset_name, presets["balanced"]) return presets.get(preset_name, presets["balanced"])