From 2c91af006234450e7d2ac9f742282d63939fc315 Mon Sep 17 00:00:00 2001 From: nick2000713 Date: Sat, 13 Jun 2026 22:22:08 +0200 Subject: [PATCH] feat: global AudioQuality model with post-download quarantine + retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/download_plugins/types.py | 30 +++- core/imports/file_ops.py | 73 +++++++++ core/imports/guards.py | 70 ++++++--- core/imports/pipeline.py | 35 +++-- core/quality/__init__.py | 0 core/quality/model.py | 261 +++++++++++++++++++++++++++++++++ core/soulseek_client.py | 197 +++++-------------------- database/music_database.py | 180 +++++++---------------- 8 files changed, 531 insertions(+), 315 deletions(-) create mode 100644 core/quality/__init__.py create mode 100644 core/quality/model.py diff --git a/core/download_plugins/types.py b/core/download_plugins/types.py index 59d52913..8ea88bb7 100644 --- a/core/download_plugins/types.py +++ b/core/download_plugins/types.py @@ -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""" diff --git a/core/imports/file_ops.py b/core/imports/file_ops.py index c3620d78..7539d4a4 100644 --- a/core/imports/file_ops.py +++ b/core/imports/file_ops.py @@ -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: diff --git a/core/imports/guards.py b/core/imports/guards.py index f4519a5d..49563a77 100644 --- a/core/imports/guards.py +++ b/core/imports/guards.py @@ -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})" + ) diff --git a/core/imports/pipeline.py b/core/imports/pipeline.py index b6a077a9..10883a05 100644 --- a/core/imports/pipeline.py +++ b/core/imports/pipeline.py @@ -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 diff --git a/core/quality/__init__.py b/core/quality/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/core/quality/model.py b/core/quality/model.py new file mode 100644 index 00000000..1ce4ab67 --- /dev/null +++ b/core/quality/model.py @@ -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 diff --git a/core/soulseek_client.py b/core/soulseek_client.py index 9843b21e..e513a957 100644 --- a/core/soulseek_client.py +++ b/core/soulseek_client.py @@ -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: diff --git a/database/music_database.py b/database/music_database.py index 0d06a6db..eedc64ad 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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"])