From 2c91af006234450e7d2ac9f742282d63939fc315 Mon Sep 17 00:00:00 2001 From: nick2000713 Date: Sat, 13 Jun 2026 22:22:08 +0200 Subject: [PATCH 01/77] 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"]) From 4cc2401332a917fe86459dd9534307ff2390439c Mon Sep 17 00:00:00 2001 From: nick2000713 Date: Sat, 13 Jun 2026 22:24:17 +0200 Subject: [PATCH 02/77] docs: add PLAN.md for global quality system feature Co-Authored-By: Claude Sonnet 4.6 --- PLAN.md | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..60a86c28 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,106 @@ +# Plan: Global Quality System — `feature/global-quality-system` + +## Was bereits implementiert ist (dieser Commit) + +### 1. `core/quality/model.py` — Das Herzstück +Source-agnostisches Datenmodell. **Jede** Quelle mappt ihre Ergebnisse auf `AudioQuality`, der gleiche Ranker läuft für alle. + +``` +AudioQuality(format, bitrate, sample_rate, bit_depth) +QualityTarget(format, bit_depth, min_sample_rate, min_bitrate, label) +filter_and_rank(candidates, targets) ← eine Funktion für alle Quellen +``` + +### 2. Soulseek — echte Werte statt Heuristik +slskd `attributes` werden jetzt ausgelesen: +- `type 4` = sample_rate (Hz) +- `type 5` = bit_depth (bits) + +Vorher: kbps-Schwellwert-Hack (1450 kbps = "wahrscheinlich 24-bit"). Jetzt: echte Werte direkt aus dem Protokoll. + +### 3. Quality Profile v3 +Statt `qualities: {flac: {enabled, priority, bit_depth}}` jetzt eine **geordnete Liste**: +```json +"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/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}, + ... +] +``` +v2-Profile werden automatisch migriert. + +### 4. Post-Download Verifikation (alle Quellen) +`probe_audio_quality()` liest die **echte heruntergeladene Datei** mit mutagen: +- FLAC: sample_rate + bit_depth + bitrate +- MP3: bitrate + sample_rate +- M4A/AAC/OGG/OPUS/WAV: bitrate + sample_rate + +`check_quality_target()` prüft gegen die `ranked_targets`: +- Match → akzeptiert +- Kein Match + `fallback_enabled=False` → Quarantäne +- Nach Quarantäne → **Retry mit nächstem Kandidaten** (wie AcoustID) + +--- + +## Was noch fehlt — nächste Schritte + +### A) Andere Quellen anbinden (Source-Mapper) +Diese Quellen geben keine echten Werte, haben aber definierte Tier-Strings die wir mappen: + +| Quelle | Tier-String | AudioQuality | +|--------|------------|--------------| +| Tidal | `'hires'` | `flac, 24bit, 96kHz` | +| Tidal | `'lossless'` | `flac, 16bit, 44.1kHz` | +| HiFi | gleich wie Tidal | — | +| Deezer | `'flac'` | `flac, 16bit, 44.1kHz` | + +**Wo**: `core/quality/model.py` — `TIDAL_TIER_MAP`, `DEEZER_TIER_MAP` hinzufügen +**Warum**: Damit `filter_and_rank()` auch für diese Quellen entscheiden kann ob eine Quelle das Ziel erfüllt (z.B. "ich will 24-bit, Tidal lossless reicht nicht") + +**Qobuz ist Sonderfall** — liefert echte `maximum_sampling_rate` + `maximum_bit_depth` aus der API, diese direkt in `AudioQuality` befüllen. + +### B) UI — Ranked Targets als editierbare Liste +Aktuell zeigt das Settings-UI noch die alte `qualities`-Ansicht (FLAC on/off, MP3 320 on/off). + +Neu: Drag & Drop Prioritätsliste in den Settings: +``` +[↕] FLAC 24-bit/192kHz [🗑] +[↕] FLAC 24-bit/96kHz [🗑] +[↕] FLAC 24-bit/44.1kHz [🗑] +[↕] FLAC 16-bit [🗑] +[↕] MP3 320kbps [🗑] +[+ Ziel hinzufügen] +``` + +**Wo**: `webui/static/settings.js` + `webui/index.html` +**API**: `GET/POST /api/quality-profile` existiert bereits, liefert jetzt v3 + +### C) Quarantäne-UI — Grund anzeigen +Wenn eine Datei wegen Quality quarantiniert wird, sollte das UI den Grund klar anzeigen: +- Welches Target wurde gesucht +- Was die Datei tatsächlich hat +- "Approve" Button setzt `_skip_quarantine_check = 'quality'` + +### D) Tests +- `tests/quality/test_model.py` — `AudioQuality.matches_target()`, `filter_and_rank()`, Migration +- `tests/imports/test_quality_guard.py` — `check_quality_target()` mit verschiedenen Szenarien + +--- + +## Branch auf neuem PC holen + +```bash +git clone https://github.com/nick2000713/SoulSync.git +cd SoulSync +git fetch origin +git checkout feature/global-quality-system +``` + +Oder wenn der Fork schon geklont ist: +```bash +git fetch origin +git checkout feature/global-quality-system +``` From 97242cbd8d4d0038eb1f54f1b73270790ad8ca7d Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 14 Jun 2026 12:15:56 +0200 Subject: [PATCH 03/77] docs: global quality system design spec + Monochrome 30s bug note Design for source-binding + quality-aware fall-through ranking (per-source population, source-priority-king), ranked-targets UI, quarantine-reason surfacing, and tests. Locks the constraints that quality quarantine reuses the trigger='quality' retry path and never sets force_imported (reserved for AcoustID mismatches). Co-Authored-By: Claude Opus 4.8 --- PLAN.md | 21 ++ ...2026-06-14-global-quality-system-design.md | 180 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-14-global-quality-system-design.md diff --git a/PLAN.md b/PLAN.md index 60a86c28..01a464f1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -45,6 +45,27 @@ v2-Profile werden automatisch migriert. --- +## Bekannte Bugs / offene Fragen + +### BUG: HiFi/Monochrome liefert nur 30s Audio — wird nicht erkannt + +**Symptom:** Jeder Download von Monochrome-Instanzen enthält nur ~30 Sekunden echtes Audio. Der Rest der Datei ist Stille. + +**Warum es durchrutscht:** +- Monochrome liefert einen HLS-Stream, aber nur die ersten ~30s der Segmente haben Audiodaten +- ffmpeg muxed diese Segmente in einen Container mit der **vollen Laufzeit** (z.B. 3:30) — stille Segmente = Stille in der Datei +- `mutagen` liest die Container-Länge (3:30 ✓) → Duration-Check besteht +- `probe_audio_quality()` prüft Format/Bitrate/Sample-Rate → alles OK +- Niemand prüft den **tatsächlichen Audioinhalt** — die Stille bleibt unerkannt + +**Qobuz hat bereits einen Fix** (`duration_s < 35` in `qobuz_client.py:1315`) — aber der greift nur wenn die Datei selbst kurz ist, nicht beim Silence-Padding. + +**Geplanter Fix:** `ffmpeg silencedetect` nach dem Download. Wenn die letzten X% der Datei unter -50dB → Preview → Quarantäne/Retry. + +**Offene Frage (erst klären!):** Ist das ein bekanntes Monochrome-Problem (Preview-Limitation der API), oder ein Bug im HLS-Downloader von SoulSync? + +--- + ## Was noch fehlt — nächste Schritte ### A) Andere Quellen anbinden (Source-Mapper) diff --git a/docs/superpowers/specs/2026-06-14-global-quality-system-design.md b/docs/superpowers/specs/2026-06-14-global-quality-system-design.md new file mode 100644 index 00000000..df7dbbfa --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-global-quality-system-design.md @@ -0,0 +1,180 @@ +# Global Quality System — Source Binding, Ranking & UI + +**Branch:** `feature/global-quality-system` +**Date:** 2026-06-14 +**Status:** Approved design — ready for implementation plan + +## Problem + +The quality model (`core/quality/model.py`) is complete and Soulseek already +uses it, but the system has no cross-cutting quality behaviour: + +1. **Streaming sources don't populate real quality.** `SearchResult.audio_quality` + is a derived property over `format/bitrate/sample_rate/bit_depth`, but Tidal, + HiFi, Deezer, Qobuz, Amazon, YouTube never fill `sample_rate`/`bit_depth` (and + sometimes not even the right `format`). Their `audio_quality` therefore falls + back to crude kbps heuristics. +2. **Ranking is inconsistent.** Only the Soulseek path runs + `filter_results_by_quality_preference`. Streaming results are ordered by + match-confidence only — quality is ignored. +3. **No quality-aware source fall-through.** `search_with_fallback` is + "first non-empty source wins": it never escalates to the next source when the + current source can't deliver the wanted quality. +4. **No UI for the v3 ranked-target list.** The profile model is v3 (ordered + target list) but there is no editor for it. + +## Decisions (locked) + +- **Per-Source Population, not cross-source pooling.** Source priority (the + hybrid chain order) stays king. Each source populates an accurate + `audio_quality`; the chain fall-through becomes quality-aware so a source that + cannot meet *any* target is skipped in favour of the next source. +- **Full scope:** source mappers (A) + ranking wiring + streaming-path unify, + UI ranked-target editor (B), quarantine-reason surfacing (C), tests (D). +- **Bitrate is a settable minimum threshold (a range "≥ X"), never an exact + match.** Lossless (FLAC/WAV) is matched on `bit_depth`/`sample_rate`; bitrate is + only a fallback heuristic when those are absent. This is already how + `AudioQuality.matches_target` behaves — the work is to expose it correctly in + the UI and add a small VBR tolerance for lossy presets. + +## Hard constraints (must not regress) + +These are already correct in the codebase and the new work must preserve them: + +- **Retry harmony.** A quality reject already flows through the same retry path as + AcoustID: `check_quality_target` → `move_to_quarantine(trigger='quality')` → + `requeue_quarantined_task_for_retry(..., 'quality')` (pipeline.py:584-620). The + worker walks the next-best candidate using the per-source retry budget. New code + must keep `check_quality_target` returning a reason string that feeds this path. +- **force_import isolation.** `force_imported` status is set ONLY by the AcoustID + version-mismatch fallback (`core/imports/version_mismatch_fallback.py`). The + quality guard sets NO verification status — it quarantines with + `trigger='quality'` and the bypass flag `_skip_quarantine_check='quality'`. A + quality mismatch must NEVER become `force_imported`. force_import stays reserved + for AcoustID mismatches. + +## A — Source mappers + +New module **`core/quality/source_map.py`** centralises each source's tier +knowledge (kept out of `model.py` to avoid bloat). Each download client populates +the four quality fields (`quality`, `bitrate`, `sample_rate`, `bit_depth`) of its +`TrackResult` via these helpers; `audio_quality` then derives automatically. + +Each tier value is a **claim**, verified post-download by `check_quality_target` +reading the real file. Ranking uses the claim to pick; the guard catches lies. + +| Source | Source of values | Mapping | +|--------|------------------|---------| +| **Soulseek** | slskd attrs type 4 (sample_rate) / 5 (bit_depth) — real | already done (`AudioQuality.from_slskd_file`) | +| **Qobuz** | API `maximum_sampling_rate` (kHz) + `maximum_bit_depth` — real | `sample_rate = rate*1000`, `bit_depth`, `format='flac'` | +| **Deezer** | config code `flac`/`mp3_320`/`mp3_128` | flac→16-bit/44.1kHz · mp3_320→320kbps · mp3_128→128kbps | +| **Tidal** | track `audioQuality` tier | HI_RES_LOSSLESS/HI_RES→flac 24/96 · LOSSLESS→flac 16/44.1 · HIGH→aac 320 · LOW→aac 96 | +| **HiFi / Monochrome** | Tidal-backed; config quality key | same map as Tidal | +| **Amazon** | real `sampleRate` when present, else tier | UHD→24/96 · HD→16/44.1; prefer real sampleRate | +| **YouTube / SoundCloud** | yt-dlp / stream `format` + `abr` | lossy: format + bitrate, no bit_depth | + +Helper shape: + +```python +# core/quality/source_map.py +TIDAL_TIER_MAP: dict[str, AudioQuality] +AMAZON_TIER_MAP: dict[str, AudioQuality] + +def quality_from_tidal_tier(tier: str) -> AudioQuality: ... +def quality_from_qobuz(sampling_rate_khz: float, bit_depth: int) -> AudioQuality: ... +def quality_from_deezer(code: str) -> AudioQuality: ... +def quality_from_amazon(stream_info: dict) -> AudioQuality: ... +``` + +Clients copy the result onto the `TrackResult` fields (small helper +`TrackResult.set_quality(aq)` to avoid four-line repetition at each call site). + +## Wiring — quality-aware fall-through + +New helper **`core/quality/selection.py`**: + +```python +def rank_for_profile(candidates) -> tuple[list, bool]: + """Return (ranked_candidates, satisfied_a_target). + + satisfied = filter_and_rank(fallback_enabled=False) produced anything. + """ +``` + +`search_with_fallback` (`core/download_engine/engine.py`) changes from +"first non-empty wins" to: + +``` +best_fallback = [] +for source in chain: + tracks, albums = source.search(query) + if not tracks: continue + ranked, satisfied = rank_for_profile(tracks) + if satisfied: + return ranked, albums # this source meets a target → done + best_fallback = best_fallback or (ranked, albums) +# chain exhausted, nothing satisfied a target +return best_fallback if fallback_enabled else ([], []) +``` + +**Key behavioural note:** with source-priority-king plus a long target list +(down to MP3 192), fall-through only triggers when a source meets *no* target at +all (below the floor) — e.g. user wants only FLAC and a source has only MP3. +Otherwise the first source that returns acceptable results wins, exactly as today +but now quality-ranked within. + +## Streaming-path unification + +In `download_orchestrator.search_and_download_best`, both paths converge on: +**match-filter first (right track), then quality-rank (best version).** The +streaming branch keeps its confidence filter (≥0.55) and then applies +`rank_for_profile` to the survivors; the Soulseek branch keeps its quality ranking +and is unchanged in spirit. No path is left quality-blind. + +## B — Ranked-targets UI + +A draggable, ordered list in the quality settings panel: + +- Each row: drag handle, label, format, the relevant constraint + (bit_depth + min_sample_rate for lossless; **min_bitrate as a settable "≥ X + kbps" field** for lossy), delete button. +- "Add target" control with format/bit_depth/sample_rate/bitrate inputs. +- `fallback_enabled` toggle. +- Persists via the existing `GET/POST /api/quality-profile` (already v3-shaped). + +Bitrate field is explicitly a **minimum threshold**, defaulting to small VBR +headroom for the common presets (e.g. a "320" preset stores `min_bitrate≈315` so +VBR/mono files near 320 still match). Lossless rows hide the bitrate field since +they match on bit_depth/sample_rate. + +## C — Quarantine reason + +Mostly wiring — the UI already carries `quarantineReason` dataset and an approve +flow (`webui/static/downloads.js`), and `check_quality_target` already returns a +"file is X, wanted Y" string. + +- Ensure the quality rejection reason is threaded into the quarantine record's + reason field and rendered in the track-detail modal (what was wanted vs what the + file actually is). +- "Approve anyway" sets `_skip_quarantine_check='quality'` (the bypass already + honoured at pipeline.py:584-585). + +## D — Tests + +- `tests/quality/test_source_map.py` — each mapper produces the expected + `AudioQuality` (Tidal tiers, Qobuz kHz→Hz, Deezer codes, Amazon real-vs-tier, + lossy no bit_depth). +- `tests/quality/test_selection.py` — `rank_for_profile` satisfied/unsatisfied; + fall-through: source A below floor → engine escalates to source B; nothing + matches with `fallback_enabled` on (best returned) vs off (empty). +- Extend `tests/quality/test_model.py` — `matches_target` bitrate-as-minimum, + FLAC matched on bit_depth/sample_rate not bitrate, v2→v3 migration. +- `tests/imports/test_quality_guard.py` — `check_quality_target` quarantine + reason scenarios **plus a regression test asserting a quality mismatch uses + `trigger='quality'` and never sets `force_imported`.** + +## Out of scope + +- Cross-source candidate pooling (rejected in favour of per-source population). +- The Monochrome/HiFi 30-second-silence bug (tracked separately in `PLAN.md`; + needs ffmpeg `silencedetect`, not tier mapping). From 12341f006ba14d7e4084087fc77836831ddffe6b Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 14 Jun 2026 12:27:03 +0200 Subject: [PATCH 04/77] feat(quality): source mappers + populate real quality on streaming results Add core/quality/source_map.py centralising each source's tier->AudioQuality mapping (Tidal/HiFi tiers, Qobuz real kHz/bit-depth, Deezer codes, Amazon codec/tier). Add TrackResult.set_quality() to merge a mapped AudioQuality onto a result. Wire HiFi, Qobuz, Deezer, Tidal, Amazon search results to stamp real sample_rate/bit_depth so the global ranker no longer relies on crude kbps heuristics for streaming sources. Fixes Qobuz/Amazon display labels ('FLAC 24-bit/192kHz', 'Lossless') breaking format derivation. Tested: 22 passing (mappers + set_quality merge semantics). Co-Authored-By: Claude Opus 4.8 --- core/amazon_download_client.py | 14 ++- core/deezer_download_client.py | 8 +- core/download_plugins/types.py | 16 +++ core/hifi_client.py | 6 + core/qobuz_client.py | 9 ++ core/quality/source_map.py | 104 ++++++++++++++++++ core/tidal_download_client.py | 5 + tests/quality/test_source_map.py | 122 +++++++++++++++++++++ tests/quality/test_track_result_quality.py | 39 +++++++ 9 files changed, 319 insertions(+), 4 deletions(-) create mode 100644 core/quality/source_map.py create mode 100644 tests/quality/test_source_map.py create mode 100644 tests/quality/test_track_result_quality.py diff --git a/core/amazon_download_client.py b/core/amazon_download_client.py index 18e4bad1..04c817ec 100644 --- a/core/amazon_download_client.py +++ b/core/amazon_download_client.py @@ -32,6 +32,7 @@ from config.settings import config_manager from core.amazon_client import AmazonClient, AmazonClientError from core.download_plugins.base import DownloadSourcePlugin from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult +from core.quality.model import AudioQuality from utils.logging_config import get_logger logger = get_logger("amazon_download_client") @@ -133,11 +134,17 @@ class AmazonDownloadClient(DownloadSourcePlugin): album_map: Dict[str, AlbumResult] = {} album_order: List[str] = [] preferred = self._client.preferred_codec + # Search results only carry the codec (real sample_rate arrives at + # stream time). Claim the format honestly — FLAC for the lossless + # codec, lossy otherwise — so audio_quality derives a real format + # instead of the display label ("Lossless"), and the post-download + # probe pins the actual sample_rate/bit_depth. + amazon_q = AudioQuality(format='flac' if _codec_key(preferred) == 'flac' else 'aac') for item in items: quality = _quality_label(preferred) if item.is_track: - track_results.append(TrackResult( + tr = TrackResult( username="amazon", filename=f"{item.asin}||{item.artist_name} - {item.title}", size=0, @@ -155,7 +162,9 @@ class AmazonDownloadClient(DownloadSourcePlugin): "album_asin": item.album_asin, "isrc": item.isrc, }, - )) + ) + tr.set_quality(amazon_q) + track_results.append(tr) elif item.is_album: album_asin = item.album_asin or item.asin if album_asin not in album_map: @@ -173,6 +182,7 @@ class AmazonDownloadClient(DownloadSourcePlugin): title=item.title, album=item.album_name, ) + placeholder.set_quality(amazon_q) album_map[album_asin] = AlbumResult( username="amazon", album_path=album_asin, diff --git a/core/deezer_download_client.py b/core/deezer_download_client.py index 727c7006..c0f654d7 100644 --- a/core/deezer_download_client.py +++ b/core/deezer_download_client.py @@ -21,6 +21,7 @@ from typing import Any, Dict, List, Optional, Tuple import requests from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult +from core.quality.source_map import quality_from_deezer from utils.logging_config import get_logger logger = get_logger("deezer_download") @@ -581,7 +582,7 @@ class DeezerDownloadClient(DownloadSourcePlugin): bitrate = 128 quality = 'mp3' - results.append(TrackResult( + tr = TrackResult( username='deezer_dl', filename=f"{track_id}||{artist} - {title}", size=est_size, @@ -595,7 +596,10 @@ class DeezerDownloadClient(DownloadSourcePlugin): title=title, album=album, track_number=item.get('track_position'), - )) + ) + # Stamp CD-quality FLAC (16/44.1) so lossless ranks correctly. + tr.set_quality(quality_from_deezer(self._quality)) + results.append(tr) logger.info(f"Deezer search for '{query}' returned {len(results)} results") return results, [] diff --git a/core/download_plugins/types.py b/core/download_plugins/types.py index 8ea88bb7..5c364da8 100644 --- a/core/download_plugins/types.py +++ b/core/download_plugins/types.py @@ -48,6 +48,22 @@ class SearchResult: bit_depth=self.bit_depth, ) + def set_quality(self, aq: AudioQuality) -> None: + """Merge a mapped :class:`AudioQuality` onto this result's fields. + + Used by streaming sources to stamp their claimed tier (Tidal/HiFi + tier strings, Qobuz API values, …) so ``audio_quality`` ranks + correctly. Mapper-provided fields win; a ``None`` from the mapper + leaves any already-reported value (e.g. a probed bitrate) intact. + """ + self.quality = aq.format + if aq.bitrate is not None: + self.bitrate = aq.bitrate + if aq.sample_rate is not None: + self.sample_rate = aq.sample_rate + if aq.bit_depth is not None: + self.bit_depth = aq.bit_depth + @property def quality_score(self) -> float: quality_weights = { diff --git a/core/hifi_client.py b/core/hifi_client.py index a1164e8f..e56c211f 100644 --- a/core/hifi_client.py +++ b/core/hifi_client.py @@ -36,6 +36,7 @@ import requests as http_requests from utils.logging_config import get_logger from config.settings import config_manager from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus +from core.quality.source_map import quality_from_tidal_tier logger = get_logger("hifi_client") @@ -754,10 +755,15 @@ class HiFiClient(DownloadSourcePlugin): quality_key = config_manager.get('hifi_download.quality', 'lossless') q_info = HLS_QUALITY_MAP.get(quality_key, HLS_QUALITY_MAP['lossless']) + # HiFi is Tidal-backed; stamp the configured tier so the global + # ranker sees real sample_rate/bit_depth, not just 'flac'. + tier_quality = quality_from_tidal_tier(quality_key) + results = [] for t in tracks: try: tr = self._to_track_result(t, q_info) + tr.set_quality(tier_quality) results.append(tr) except Exception as e: logger.debug(f"Skipping track result conversion: {e}") diff --git a/core/qobuz_client.py b/core/qobuz_client.py index cf4fbf9b..addf31a9 100644 --- a/core/qobuz_client.py +++ b/core/qobuz_client.py @@ -29,6 +29,7 @@ from config.settings import config_manager # Import Soulseek data structures for drop-in replacement compatibility from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus +from core.quality.source_map import quality_from_qobuz logger = get_logger("qobuz_client") @@ -1072,6 +1073,14 @@ class QobuzClient(DownloadSourcePlugin): track_number=track.get('track_number'), ) + # Stamp real API quality so the global ranker sees actual + # sample_rate/bit_depth. Hi-res only when the track itself is + # hires-streamable; otherwise it streams as CD-quality FLAC. + if hires_streamable and max_bit_depth >= 24: + track_result.set_quality(quality_from_qobuz(max_sample_rate, max_bit_depth)) + else: + track_result.set_quality(quality_from_qobuz(44.1, 16)) + return track_result # ===================== Download ===================== diff --git a/core/quality/source_map.py b/core/quality/source_map.py new file mode 100644 index 00000000..7372dccb --- /dev/null +++ b/core/quality/source_map.py @@ -0,0 +1,104 @@ +"""Per-source quality mappers — turn each download source's tier string or +API values into a unified :class:`~core.quality.model.AudioQuality`. + +Every streaming source describes quality differently (Tidal/HiFi use tier +strings, Qobuz reports real kHz + bit depth, Deezer uses config codes, +Amazon mixes real values with HD/UHD tiers). Centralising the knowledge +here keeps the per-client code to a single call and keeps the tier tables +in one auditable place. + +Each value is a *claim*: the download client populates its ``TrackResult`` +from it so the global ranker can choose a source, and the post-download +quality guard later verifies the real file. Over-claiming is the danger — +an unknown tier maps to ``format='unknown'`` rather than pretending to be +lossless. +""" + +from __future__ import annotations + +from typing import Optional + +from core.quality.model import AudioQuality + + +# ── Tidal / HiFi (Monochrome is Tidal-backed) ────────────────────────────── +# +# Tidal exposes UPPER_SNAKE tier strings (``HI_RES_LOSSLESS``); HiFi's config +# uses lowercase keys (``hires``/``lossless``). We normalise both into the +# same lookup so one mapper serves both sources. + +_TIDAL_HIRES = AudioQuality(format='flac', sample_rate=96000, bit_depth=24) +_TIDAL_LOSSLESS = AudioQuality(format='flac', sample_rate=44100, bit_depth=16) +_TIDAL_HIGH = AudioQuality(format='aac', bitrate=320) +_TIDAL_LOW = AudioQuality(format='aac', bitrate=96) + +TIDAL_TIER_MAP = { + 'HI_RES_LOSSLESS': _TIDAL_HIRES, + 'HI_RES': _TIDAL_HIRES, + 'HIRES': _TIDAL_HIRES, + 'LOSSLESS': _TIDAL_LOSSLESS, + 'HIGH': _TIDAL_HIGH, + 'LOW': _TIDAL_LOW, +} + + +def quality_from_tidal_tier(tier: str) -> AudioQuality: + """Map a Tidal/HiFi quality tier string to an AudioQuality. + + Case-insensitive; accepts both ``HI_RES`` and ``hires`` spellings. + Unrecognised tiers map to ``format='unknown'`` so they never + over-claim lossless quality. + """ + key = (tier or '').strip().upper() + return TIDAL_TIER_MAP.get(key, AudioQuality(format='unknown')) + + +# ── Qobuz (real API values) ──────────────────────────────────────────────── + +def quality_from_qobuz(sampling_rate_khz: float, bit_depth: int) -> AudioQuality: + """Qobuz reports ``maximum_sampling_rate`` in kHz (e.g. 44.1, 96, 192) + and ``maximum_bit_depth``. These are real values from the API. + """ + sample_rate = int(round(sampling_rate_khz * 1000)) if sampling_rate_khz else None + return AudioQuality(format='flac', sample_rate=sample_rate, bit_depth=bit_depth) + + +# ── Deezer (config code) ─────────────────────────────────────────────────── + +DEEZER_CODE_MAP = { + 'flac': AudioQuality(format='flac', sample_rate=44100, bit_depth=16), + 'mp3_320': AudioQuality(format='mp3', bitrate=320), + 'mp3_128': AudioQuality(format='mp3', bitrate=128), +} + + +def quality_from_deezer(code: str) -> AudioQuality: + """Map a Deezer download quality code to AudioQuality. + + Deezer FLAC is always CD-quality (16-bit/44.1 kHz). + """ + return DEEZER_CODE_MAP.get((code or '').lower(), AudioQuality(format='unknown')) + + +# ── Amazon Music (real sampleRate preferred, HD/UHD tier fallback) ───────── + +_AMAZON_TIER_MAP = { + 'UHD': AudioQuality(format='flac', sample_rate=96000, bit_depth=24), + 'HD': AudioQuality(format='flac', sample_rate=44100, bit_depth=16), +} + + +def quality_from_amazon( + tier: str, + sample_rate: Optional[int] = None, + bit_depth: Optional[int] = None, +) -> AudioQuality: + """Amazon Music is FLAC; prefer the real ``sampleRate``/``bitDepth`` from + the stream info when present, otherwise fall back to the HD/UHD tier. + """ + base = _AMAZON_TIER_MAP.get((tier or '').strip().upper(), AudioQuality(format='flac')) + return AudioQuality( + format='flac', + sample_rate=sample_rate if sample_rate is not None else base.sample_rate, + bit_depth=bit_depth if bit_depth is not None else base.bit_depth, + ) diff --git a/core/tidal_download_client.py b/core/tidal_download_client.py index 4192606a..d59f9027 100644 --- a/core/tidal_download_client.py +++ b/core/tidal_download_client.py @@ -33,6 +33,7 @@ from config.settings import config_manager # Import Soulseek data structures for drop-in replacement compatibility from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus +from core.quality.source_map import quality_from_tidal_tier logger = get_logger("tidal_download_client") @@ -457,11 +458,15 @@ class TidalDownloadClient(DownloadSourcePlugin): quality_key = config_manager.get('tidal_download.quality', 'lossless') quality_info = QUALITY_MAP.get(quality_key, QUALITY_MAP['lossless']) + # Stamp the configured tier (what will actually be downloaded) so + # the global ranker sees real sample_rate/bit_depth. + tier_quality = quality_from_tidal_tier(quality_key) track_results = [] for track in tidal_tracks: try: track_result = self._tidal_to_track_result(track, quality_info) + track_result.set_quality(tier_quality) track_results.append(track_result) except Exception as e: logger.debug(f"Skipping track conversion error: {e}") diff --git a/tests/quality/test_source_map.py b/tests/quality/test_source_map.py new file mode 100644 index 00000000..ffd5199e --- /dev/null +++ b/tests/quality/test_source_map.py @@ -0,0 +1,122 @@ +"""Tests for core.quality.source_map — each download source's tier/string +mapped into a unified AudioQuality. + +These mappers express each source's *claimed* capability tier. The values +are later verified post-download by the quality guard, so over-claiming is +the failure mode to avoid: an unknown tier must NOT pretend to be lossless. +""" + +import pytest + +from core.quality.source_map import ( + quality_from_tidal_tier, + quality_from_qobuz, + quality_from_deezer, + quality_from_amazon, +) + + +# ── Tidal / HiFi (Tidal-backed) ──────────────────────────────────────────── + +@pytest.mark.parametrize("tier", ["HI_RES_LOSSLESS", "HI_RES", "hi_res", "hires", "HIRES"]) +def test_tidal_hires_is_flac_24_96(tier): + aq = quality_from_tidal_tier(tier) + assert aq.format == "flac" + assert aq.bit_depth == 24 + assert aq.sample_rate == 96000 + + +@pytest.mark.parametrize("tier", ["LOSSLESS", "lossless"]) +def test_tidal_lossless_is_flac_16_44(tier): + aq = quality_from_tidal_tier(tier) + assert aq.format == "flac" + assert aq.bit_depth == 16 + assert aq.sample_rate == 44100 + + +def test_tidal_high_is_lossy_aac_320(): + aq = quality_from_tidal_tier("HIGH") + assert aq.format == "aac" + assert aq.bitrate == 320 + assert aq.bit_depth is None # lossy: no bit depth + + +def test_tidal_low_is_lossy_aac_96(): + aq = quality_from_tidal_tier("LOW") + assert aq.format == "aac" + assert aq.bitrate == 96 + + +def test_tidal_unknown_tier_does_not_overclaim(): + # An unrecognised tier must not masquerade as lossless. + aq = quality_from_tidal_tier("SOMETHING_NEW") + assert aq.format == "unknown" + assert aq.bit_depth is None + assert aq.sample_rate is None + + +# ── Qobuz (real API values) ──────────────────────────────────────────────── + +def test_qobuz_hires_khz_to_hz(): + aq = quality_from_qobuz(96.0, 24) + assert aq.format == "flac" + assert aq.sample_rate == 96000 + assert aq.bit_depth == 24 + + +def test_qobuz_cd_quality_fractional_khz(): + aq = quality_from_qobuz(44.1, 16) + assert aq.format == "flac" + assert aq.sample_rate == 44100 + assert aq.bit_depth == 16 + + +def test_qobuz_192k(): + aq = quality_from_qobuz(192.0, 24) + assert aq.sample_rate == 192000 + assert aq.bit_depth == 24 + + +# ── Deezer (config code) ─────────────────────────────────────────────────── + +def test_deezer_flac_is_16_44(): + aq = quality_from_deezer("flac") + assert aq.format == "flac" + assert aq.bit_depth == 16 + assert aq.sample_rate == 44100 + + +def test_deezer_mp3_320(): + aq = quality_from_deezer("mp3_320") + assert aq.format == "mp3" + assert aq.bitrate == 320 + assert aq.bit_depth is None + + +def test_deezer_mp3_128(): + aq = quality_from_deezer("mp3_128") + assert aq.format == "mp3" + assert aq.bitrate == 128 + + +# ── Amazon (real sampleRate preferred, tier fallback) ────────────────────── + +def test_amazon_prefers_real_sample_rate(): + aq = quality_from_amazon("HD", sample_rate=88200, bit_depth=24) + assert aq.format == "flac" + assert aq.sample_rate == 88200 + assert aq.bit_depth == 24 + + +def test_amazon_hd_tier_fallback(): + aq = quality_from_amazon("HD") + assert aq.format == "flac" + assert aq.sample_rate == 44100 + assert aq.bit_depth == 16 + + +def test_amazon_uhd_tier_fallback(): + aq = quality_from_amazon("UHD") + assert aq.format == "flac" + assert aq.bit_depth == 24 + assert aq.sample_rate == 96000 diff --git a/tests/quality/test_track_result_quality.py b/tests/quality/test_track_result_quality.py new file mode 100644 index 00000000..047fd0df --- /dev/null +++ b/tests/quality/test_track_result_quality.py @@ -0,0 +1,39 @@ +"""TrackResult.set_quality() — merge a mapped AudioQuality onto a result so +its derived ``audio_quality`` reflects the source's real/claimed tier. +""" + +from core.download_plugins.types import TrackResult +from core.quality.model import AudioQuality + + +def _tr(**kw): + base = dict( + username='x', filename='f', size=0, bitrate=None, duration=None, + quality='', free_upload_slots=0, upload_speed=0, queue_length=0, + ) + base.update(kw) + return TrackResult(**base) + + +def test_set_quality_populates_lossless_fields(): + tr = _tr(quality='flac', bitrate=1411) + tr.set_quality(AudioQuality('flac', sample_rate=96000, bit_depth=24)) + assert tr.quality == 'flac' + assert tr.sample_rate == 96000 + assert tr.bit_depth == 24 + # derived descriptor must reflect the merge + assert tr.audio_quality.sample_rate == 96000 + assert tr.audio_quality.bit_depth == 24 + + +def test_set_quality_keeps_existing_bitrate_when_mapper_has_none(): + tr = _tr(quality='flac', bitrate=950) + tr.set_quality(AudioQuality('flac', sample_rate=44100, bit_depth=16)) + assert tr.bitrate == 950 # mapper bitrate is None → preserve probed/reported + + +def test_set_quality_overwrites_bitrate_for_lossy(): + tr = _tr(quality='mp3', bitrate=128) + tr.set_quality(AudioQuality('mp3', bitrate=320)) + assert tr.bitrate == 320 + assert tr.bit_depth is None From 310a5fe1bd31af79eeda629278e33c3d29b6d063 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 14 Jun 2026 12:33:34 +0200 Subject: [PATCH 05/77] feat(quality): quality-aware source fall-through in search_with_fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add core/quality/selection.py: rank_with_targets() returns (ranked, satisfied) where satisfied = a candidate meets a real target (strict). load_profile_targets()/rank_for_profile() are the DB-backed wrappers. search_with_fallback now skips a source that can deliver no target-meeting quality and escalates to the next (source priority still wins among satisfying sources; first source's results kept as fallback unless the profile disables it). Returns RAW tracks — the satisfied check is a coarse source gate; match-filtering + final ranking stay in the orchestrator so the correct track is never pruned. Ranking is fail-open: a ranking error never drops a source's real results. Tested: rank_with_targets satisfied/fallback matrix + engine escalation, stop-on-first, raw-not-pruned, fallback on/off. Amazon field test updated for the corrected format token. Co-Authored-By: Claude Opus 4.8 --- core/download_engine/engine.py | 48 ++++++++- core/quality/selection.py | 80 +++++++++++++++ tests/quality/test_engine_fallback.py | 108 +++++++++++++++++++++ tests/quality/test_selection.py | 64 ++++++++++++ tests/tools/test_amazon_download_client.py | 5 +- 5 files changed, 301 insertions(+), 4 deletions(-) create mode 100644 core/quality/selection.py create mode 100644 tests/quality/test_engine_fallback.py create mode 100644 tests/quality/test_selection.py diff --git a/core/download_engine/engine.py b/core/download_engine/engine.py index 8aaa07e8..d3f04a82 100644 --- a/core/download_engine/engine.py +++ b/core/download_engine/engine.py @@ -28,6 +28,7 @@ from __future__ import annotations import threading from typing import Any, Dict, Iterator, List, Optional, Tuple +from core.quality.selection import load_profile_targets, rank_with_targets from utils.logging_config import get_logger logger = get_logger("download_engine") @@ -391,9 +392,21 @@ class DownloadEngine: (tracks, albums) tuple, or ``([], [])`` when every source in the chain is exhausted. + Quality-aware: a source is only accepted when it can deliver a + candidate that meets a real target in the user's quality profile. + A source that meets no target is skipped in favour of the next one + (source priority still wins among satisfying sources). RAW tracks are + returned — the per-source ``satisfied`` check is a coarse gate; + match-filtering and final quality ranking happen in the orchestrator. + When no source satisfies a target, the first source's results are + returned as a fallback (unless the profile disables fallback). + Replaces orchestrator's hand-rolled hybrid search loop. The chain is ordered (most-preferred first). """ + targets, fallback_enabled = load_profile_targets() + first_fallback = None # (tracks, albums) of the first source with hits + for i, source_name in enumerate(source_chain): plugin = self._plugins.get(source_name) if plugin is None: @@ -406,14 +419,43 @@ class DownloadEngine: try: logger.info(f"Trying {source_name} (priority {i+1}): {query}") tracks, albums = await plugin.search(query, timeout, progress_callback) - if tracks: - logger.info(f"{source_name} found {len(tracks)} tracks") + if not tracks: + continue + try: + _, satisfied = rank_with_targets( + tracks, targets, fallback_enabled=fallback_enabled, + ) + except Exception as rank_err: + # Fail open: a ranking bug must never drop a source's real + # download results. Accept them as-is. + logger.warning( + f"Quality ranking failed for {source_name} — accepting " + f"its results unranked: {rank_err}" + ) + satisfied = True + if satisfied: + logger.info( + f"{source_name} found {len(tracks)} tracks (meets quality target)" + ) return (tracks, albums) + logger.info( + f"{source_name} found {len(tracks)} tracks but none meet a " + f"quality target — trying next source" + ) + if first_fallback is None: + first_fallback = (tracks, albums) except Exception as e: logger.warning(f"{source_name} search failed: {e}") + if fallback_enabled and first_fallback is not None: + logger.info( + "Hybrid search: no source met a quality target — falling back to " + "first source's results" + ) + return first_fallback + logger.warning( - "Hybrid search: all sources (%s) found nothing for: %s", + "Hybrid search: all sources (%s) found nothing acceptable for: %s", ', '.join(source_chain), query, ) return ([], []) diff --git a/core/quality/selection.py b/core/quality/selection.py new file mode 100644 index 00000000..fc9e8ef4 --- /dev/null +++ b/core/quality/selection.py @@ -0,0 +1,80 @@ +"""Quality-aware candidate selection shared by the search engine and the +download orchestrator. + +``rank_with_targets`` is the pure core: it ranks candidates against a target +list and reports whether any candidate met a *real* target (strict, fallback +off). The engine uses that ``satisfied`` flag to decide whether the current +source is good enough or it should fall through to the next source in the +hybrid chain. + +``rank_for_profile`` is the thin DB-backed wrapper that loads the user's +quality profile (with v2->v3 migration) and delegates. +""" + +from __future__ import annotations + +from typing import List, Tuple + +from core.quality.model import ( + QualityTarget, + filter_and_rank, + v2_qualities_to_ranked_targets, +) + + +def rank_with_targets( + candidates: list, + targets: List[QualityTarget], + *, + fallback_enabled: bool = True, +) -> Tuple[list, bool]: + """Rank *candidates* against *targets*. + + Returns ``(ranked, satisfied)`` where ``satisfied`` is True when at least + one candidate meets a real target. When no targets are configured the + profile imposes no constraint, so any non-empty result counts as + satisfied (the first source wins, quality-sorted). + """ + if not candidates: + return [], False + + if not targets: + ranked = filter_and_rank(candidates, targets, fallback_enabled=True) + return ranked, bool(ranked) + + strict = filter_and_rank(candidates, targets, fallback_enabled=False) + if strict: + return strict, True + + if fallback_enabled: + return filter_and_rank(candidates, targets, fallback_enabled=True), False + return [], False + + +def load_profile_targets() -> Tuple[List[QualityTarget], bool]: + """Load the user's quality profile from the DB and return + ``(targets, fallback_enabled)`` with v2->v3 migration applied. + + Callers that rank across many sources should load once and reuse via + :func:`rank_with_targets` rather than calling :func:`rank_for_profile` + per source. + """ + from database.music_database import MusicDatabase + + 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']) + + targets = [QualityTarget.from_dict(t) for t in (raw_targets or [])] + fallback_enabled = profile.get('fallback_enabled', True) + return targets, fallback_enabled + + +def rank_for_profile(candidates: list) -> Tuple[list, bool]: + """Load the user's quality profile and rank *candidates* against it. + + Returns ``(ranked, satisfied)`` — see :func:`rank_with_targets`. + """ + targets, fallback_enabled = load_profile_targets() + return rank_with_targets(candidates, targets, fallback_enabled=fallback_enabled) diff --git a/tests/quality/test_engine_fallback.py b/tests/quality/test_engine_fallback.py new file mode 100644 index 00000000..e85af0c3 --- /dev/null +++ b/tests/quality/test_engine_fallback.py @@ -0,0 +1,108 @@ +"""Engine search_with_fallback is quality-aware: it falls through to the next +source when the current source can deliver no target-satisfying quality, but +returns RAW tracks (match-filtering happens later in the orchestrator). +""" + +import asyncio + +import pytest + +from core.download_engine import engine as engine_mod +from core.download_engine.engine import DownloadEngine +from core.quality.model import AudioQuality, QualityTarget + + +class _Cand: + def __init__(self, aq, name): + self.audio_quality = aq + self.name = name + + +class _FakePlugin: + def __init__(self, tracks): + self._tracks = tracks + self.searched = False + + def is_configured(self): + return True + + async def search(self, query, timeout=None, progress_callback=None): + self.searched = True + return (self._tracks, []) + + +FLAC = AudioQuality('flac', sample_rate=44100, bit_depth=16) +MP3 = AudioQuality('mp3', bitrate=320) +WANT_FLAC_ONLY = [QualityTarget(label='FLAC 16', format='flac', bit_depth=16)] + + +def _engine_with(plugins): + eng = object.__new__(DownloadEngine) + eng._plugins = plugins + return eng + + +def _patch_profile(monkeypatch, targets, fallback_enabled): + monkeypatch.setattr( + engine_mod, 'load_profile_targets', + lambda: (targets, fallback_enabled), + ) + + +def test_escalates_to_next_source_when_first_cannot_meet_target(monkeypatch): + _patch_profile(monkeypatch, WANT_FLAC_ONLY, True) + first = _FakePlugin([_Cand(MP3, 'a-mp3')]) + second = _FakePlugin([_Cand(FLAC, 'b-flac')]) + eng = _engine_with({'first': first, 'second': second}) + + tracks, _ = asyncio.run(eng.search_with_fallback('q', ['first', 'second'])) + + assert [t.name for t in tracks] == ['b-flac'] # escalated to FLAC source + assert second.searched is True + + +def test_stops_on_first_satisfying_source(monkeypatch): + _patch_profile(monkeypatch, WANT_FLAC_ONLY, True) + first = _FakePlugin([_Cand(FLAC, 'a-flac')]) + second = _FakePlugin([_Cand(FLAC, 'b-flac')]) + eng = _engine_with({'first': first, 'second': second}) + + tracks, _ = asyncio.run(eng.search_with_fallback('q', ['first', 'second'])) + + assert [t.name for t in tracks] == ['a-flac'] + assert second.searched is False # never queried — source priority king + + +def test_returns_raw_tracks_not_pruned(monkeypatch): + # A source satisfied by one candidate must still return ALL its tracks so + # the orchestrator's match filter can pick the correct one. + _patch_profile(monkeypatch, WANT_FLAC_ONLY, True) + first = _FakePlugin([_Cand(MP3, 'wrong-but-present'), _Cand(FLAC, 'flac')]) + eng = _engine_with({'first': first}) + + tracks, _ = asyncio.run(eng.search_with_fallback('q', ['first'])) + + names = {t.name for t in tracks} + assert names == {'wrong-but-present', 'flac'} # nothing pruned + + +def test_no_source_satisfies_fallback_on_returns_first_source(monkeypatch): + _patch_profile(monkeypatch, WANT_FLAC_ONLY, True) + first = _FakePlugin([_Cand(MP3, 'a-mp3')]) + second = _FakePlugin([_Cand(MP3, 'b-mp3')]) + eng = _engine_with({'first': first, 'second': second}) + + tracks, _ = asyncio.run(eng.search_with_fallback('q', ['first', 'second'])) + + assert [t.name for t in tracks] == ['a-mp3'] # source priority for fallback + + +def test_no_source_satisfies_fallback_off_returns_empty(monkeypatch): + _patch_profile(monkeypatch, WANT_FLAC_ONLY, False) + first = _FakePlugin([_Cand(MP3, 'a-mp3')]) + eng = _engine_with({'first': first}) + + tracks, albums = asyncio.run(eng.search_with_fallback('q', ['first'])) + + assert tracks == [] + assert albums == [] diff --git a/tests/quality/test_selection.py b/tests/quality/test_selection.py new file mode 100644 index 00000000..1bcb6bd9 --- /dev/null +++ b/tests/quality/test_selection.py @@ -0,0 +1,64 @@ +"""core.quality.selection — quality-aware ranking + the satisfied flag that +drives the engine's source fall-through. + +A source is "satisfied" when at least one of its candidates meets a real +target (strict, fallback off). The engine uses that to decide whether to +stop on the current source or escalate to the next. +""" + +import pytest + +from core.quality.model import AudioQuality, QualityTarget +from core.quality.selection import rank_with_targets + + +class _Cand: + """Minimal candidate: filter_and_rank only needs ``.audio_quality``.""" + def __init__(self, aq, name=""): + self.audio_quality = aq + self.name = name + + def __repr__(self): + return f"_Cand({self.name})" + + +FLAC_HIRES = AudioQuality('flac', sample_rate=96000, bit_depth=24) +FLAC_CD = AudioQuality('flac', sample_rate=44100, bit_depth=16) +MP3_320 = AudioQuality('mp3', bitrate=320) + +WANT_HIRES = [QualityTarget(label='FLAC 24', format='flac', bit_depth=24, min_sample_rate=96000)] +WANT_FLAC_ONLY = [QualityTarget(label='FLAC 16', format='flac', bit_depth=16)] + + +def test_satisfied_when_a_candidate_meets_a_target(): + cands = [_Cand(MP3_320, 'mp3'), _Cand(FLAC_HIRES, 'hires')] + ranked, satisfied = rank_with_targets(cands, WANT_HIRES, fallback_enabled=True) + assert satisfied is True + assert ranked[0].name == 'hires' # the matching candidate wins + + +def test_unsatisfied_but_fallback_returns_sorted_when_enabled(): + cands = [_Cand(MP3_320, 'mp3')] + ranked, satisfied = rank_with_targets(cands, WANT_FLAC_ONLY, fallback_enabled=True) + assert satisfied is False # no FLAC → no target met + assert [c.name for c in ranked] == ['mp3'] # but fallback keeps it + + +def test_unsatisfied_and_fallback_off_returns_empty(): + cands = [_Cand(MP3_320, 'mp3')] + ranked, satisfied = rank_with_targets(cands, WANT_FLAC_ONLY, fallback_enabled=False) + assert satisfied is False + assert ranked == [] + + +def test_empty_targets_accepts_everything_satisfied(): + cands = [_Cand(MP3_320, 'mp3'), _Cand(FLAC_CD, 'cd')] + ranked, satisfied = rank_with_targets(cands, [], fallback_enabled=True) + assert satisfied is True # no constraint → first source wins + assert ranked[0].name == 'cd' # still quality-sorted + + +def test_no_candidates_is_unsatisfied(): + ranked, satisfied = rank_with_targets([], WANT_FLAC_ONLY, fallback_enabled=True) + assert satisfied is False + assert ranked == [] diff --git a/tests/tools/test_amazon_download_client.py b/tests/tools/test_amazon_download_client.py index f831f268..75c6a54b 100644 --- a/tests/tools/test_amazon_download_client.py +++ b/tests/tools/test_amazon_download_client.py @@ -224,7 +224,10 @@ class TestSearch: assert t.artist == "Kendrick Lamar" assert t.title == "Track 0" assert t.album == "GNX" - assert t.quality == "Lossless" + # Quality is now stamped as a real format token (was the display + # label "Lossless", which broke audio_quality format derivation). + assert t.quality == "flac" + assert t.audio_quality.format == "flac" assert t.duration == 200_000 def test_track_source_metadata(self, tmp_path): From 71f7c4a5e17acf4e6d9831a9dc7527c66d7e6910 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 14 Jun 2026 12:35:02 +0200 Subject: [PATCH 06/77] feat(quality): quality-rank streaming results after the match filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit search_and_download_best applied confidence scoring to streaming results but never quality-ranked them — only the Soulseek path did. Apply rank_for_profile to the confidence-passing survivors so the best version wins (match first, then quality). Stable ranking keeps confidence order within an equal tier; an "or scored" fail-safe keeps a candidate to try. Co-Authored-By: Claude Opus 4.8 --- core/download_orchestrator.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/core/download_orchestrator.py b/core/download_orchestrator.py index 1984c0a6..c2d6980a 100644 --- a/core/download_orchestrator.py +++ b/core/download_orchestrator.py @@ -412,9 +412,17 @@ class DownloadOrchestrator: if scored: scored.sort(key=lambda x: x._match_confidence, reverse=True) - filtered_results = scored + # Match filter done (right track); now prefer the best quality + # among the confidence-passing survivors so streaming isn't + # quality-blind like Soulseek already isn't. Stable ranking + # keeps confidence order within an equal quality tier; the + # `or scored` fail-safe never leaves us with nothing to try. + from core.quality.selection import rank_for_profile + ranked, _ = rank_for_profile(scored) + filtered_results = ranked or scored logger.info(f"Streaming validation: {len(scored)}/{len(tracks)} passed " - f"(best: {scored[0]._match_confidence:.2f})") + f"(best: {scored[0]._match_confidence:.2f}, " + f"quality pick: {filtered_results[0].audio_quality.label()})") else: logger.warning(f"No streaming results passed validation for: {query}") return None From e0c55342bc52a48c2fe6e95c4c6f1af63e04a232 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 14 Jun 2026 12:41:38 +0200 Subject: [PATCH 07/77] feat(quality): v3 ranked-targets UI editor (drag-to-reorder) Replace the v2 per-tier quality UI (FLAC on/off + MP3 sliders + bit-depth buttons) with a draggable ordered target list. Each row shows its rank + label with move/delete; an add form picks format and, for lossless, bit depth + min sample rate, or for lossy a minimum bitrate threshold (>=) so VBR/mono files aren't falsely rejected. Persists v3 ranked_targets via the existing /api/quality-profile. Presets + fallback toggle retained; help text and tooltip rewritten for the new top-down source-gating model. Verified: v3 profile round-trips UI shape -> DB -> load_profile_targets. Co-Authored-By: Claude Opus 4.8 --- webui/index.html | 183 ++++++----------------- webui/static/helper.js | 6 +- webui/static/settings.js | 309 ++++++++++++++++----------------------- webui/static/style.css | 144 ++++++++++++++++++ 4 files changed, 310 insertions(+), 332 deletions(-) diff --git a/webui/index.html b/webui/index.html index a93dd265..e82b0577 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5087,144 +5087,41 @@ - -
-
- - Priority: 1 + +
+ +
+
-
-
- -
- - -
-
-
- 500 kbps - - - 10000 kbps -
-
-
-
- -
- - - -
- -
- - -
-
- - Priority: 2 -
-
-
- -
- - -
-
-
- 280 kbps - - - 500 kbps -
-
-
-
- - -
-
- - Priority: 3 -
-
-
- -
- - -
-
-
- 200 kbps - - - 400 kbps -
-
-
-
- - -
-
- - Priority: 4 -
-
-
- -
- - -
-
-
- 150 kbps - - - 300 kbps -
-
+ +
@@ -5232,16 +5129,18 @@
- How it works: Downloads try each enabled quality in priority order - (1 = highest). - MIN bitrate catches fake/transcoded files (e.g., FLAC below 500 kbps is likely a - re-encoded MP3). MAX bitrate limits hi-res files if you want to save space. - When track duration is unavailable, a generous file-size safety net is used instead. + How it works: Each download source is checked against this list + top-down. The first target a source can satisfy wins; a source that meets no target + is skipped for the next one (source priority still decides between sources that can). + For lossless, bit depth + sample rate decide the match. For MP3/AAC the bitrate is a + minimum threshold (≥), so VBR and mono files aren't falsely rejected. After + download the real file is verified against the same list. With fallback off, a track + is left missing rather than accepting a quality below every target.
diff --git a/webui/static/helper.js b/webui/static/helper.js index 60bba85a..221dfe18 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -1872,9 +1872,9 @@ const HELPER_CONTENT = { title: 'Quality Preset', description: 'One-click quality configuration. Presets set all format enables, priorities, and bitrate ranges at once.', }, - '.bit-depth-btn': { - title: 'FLAC Bit Depth', - description: 'Prefer 16-bit (CD quality, smaller), 24-bit (hi-res, larger), or Any. When a specific depth is chosen, the fallback toggle controls whether other depths are accepted.', + '.ranked-targets-editor': { + title: 'Quality Priority List', + description: 'Ordered list of acceptable qualities (1st = most preferred). Each source is checked top-down; the first target it can satisfy wins. Lossless matches on bit depth + sample rate; MP3/AAC use a minimum bitrate (≥) so VBR/mono files aren\'t falsely rejected. Drag to reorder.', docsId: 'set-quality' }, '#quality-fallback-enabled': { diff --git a/webui/static/settings.js b/webui/static/settings.js index af4bb3f7..381207e7 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -1904,175 +1904,132 @@ async function loadQualityProfile() { } } +// v3: the working copy of the ordered target list. Mirrors the DOM rows +// and is the single source of truth that collectQualityProfileFromUI reads. +let currentRankedTargets = []; + +function rtLabel(t) { + const fmt = (t.format || 'any').toUpperCase(); + if (t.format === 'flac' || t.format === 'wav') { + const bd = t.bit_depth ? `${t.bit_depth}-bit` : ''; + const sr = t.min_sample_rate ? `≥${t.min_sample_rate / 1000}kHz` : ''; + const detail = [bd, sr].filter(Boolean).join('/'); + return detail ? `${fmt} ${detail}` : fmt; + } + return t.min_bitrate ? `${fmt} ≥${t.min_bitrate}kbps` : fmt; +} + function populateQualityProfileUI(profile) { // Update preset buttons - document.querySelectorAll('.preset-button').forEach(btn => { - btn.classList.remove('active'); - }); + document.querySelectorAll('.preset-button').forEach(btn => btn.classList.remove('active')); const activePresetBtn = document.querySelector(`.preset-button[onclick*="${profile.preset}"]`); - if (activePresetBtn) { - activePresetBtn.classList.add('active'); - } + if (activePresetBtn) activePresetBtn.classList.add('active'); - // Populate each quality tier - const qualities = ['flac', 'mp3_320', 'mp3_256', 'mp3_192']; - qualities.forEach(quality => { - const config = profile.qualities[quality]; - if (config) { - // Set enabled checkbox - const enabledCheckbox = document.getElementById(`quality-${quality}-enabled`); - if (enabledCheckbox) { - enabledCheckbox.checked = config.enabled; - } + // The API migrates v2 → v3, so ranked_targets is always present. + currentRankedTargets = Array.isArray(profile.ranked_targets) + ? profile.ranked_targets.map(t => ({ ...t })) + : []; + renderRankedTargets(); - // Set min/max sliders - const minSlider = document.getElementById(`${quality}-min`); - const maxSlider = document.getElementById(`${quality}-max`); - if (minSlider && maxSlider) { - minSlider.value = config.min_kbps; - maxSlider.value = config.max_kbps; - updateQualityRange(quality); - } - - // Set priority display - const prioritySpan = document.getElementById(`priority-${quality}`); - if (prioritySpan) { - prioritySpan.textContent = `Priority: ${config.priority}`; - } - - // Toggle sliders visibility - const sliders = document.getElementById(`sliders-${quality}`); - if (sliders) { - if (config.enabled) { - sliders.classList.remove('disabled'); - } else { - sliders.classList.add('disabled'); - } - } - - // FLAC-specific: restore bit depth selector and fallback toggle - if (quality === 'flac') { - const bitDepthValue = config.bit_depth || 'any'; - document.querySelectorAll('.bit-depth-btn').forEach(btn => { - btn.classList.toggle('active', btn.getAttribute('data-value') === bitDepthValue); - }); - const bitDepthSelector = document.getElementById('flac-bit-depth-selector'); - if (bitDepthSelector) { - if (config.enabled) { - bitDepthSelector.classList.remove('disabled'); - } else { - bitDepthSelector.classList.add('disabled'); - } - } - // Show/hide and restore fallback toggle - const fallbackToggle = document.getElementById('flac-fallback-toggle'); - if (fallbackToggle) { - fallbackToggle.style.display = bitDepthValue === 'any' ? 'none' : 'block'; - } - const fallbackCb = document.getElementById('flac-bit-depth-fallback'); - if (fallbackCb) { - fallbackCb.checked = config.bit_depth_fallback !== false; - } - } - } - }); - - // Set fallback checkbox const fallbackCheckbox = document.getElementById('quality-fallback-enabled'); - if (fallbackCheckbox) { - fallbackCheckbox.checked = profile.fallback_enabled; - } + if (fallbackCheckbox) fallbackCheckbox.checked = profile.fallback_enabled !== false; } -function updateQualityRange(quality) { - const minSlider = document.getElementById(`${quality}-min`); - const maxSlider = document.getElementById(`${quality}-max`); - const minValue = document.getElementById(`${quality}-min-value`); - const maxValue = document.getElementById(`${quality}-max-value`); +function renderRankedTargets() { + const list = document.getElementById('ranked-targets-list'); + if (!list) return; + list.innerHTML = ''; - if (!minSlider || !maxSlider || !minValue || !maxValue) return; - - let min = parseInt(minSlider.value); - let max = parseInt(maxSlider.value); - - // Ensure min doesn't exceed max - if (min > max) { - min = max; - minSlider.value = min; + if (currentRankedTargets.length === 0) { + list.innerHTML = '
No targets yet — add one below. ' + + 'With fallback off this would reject every download.
'; + return; } - // Ensure max doesn't go below min - if (max < min) { - max = min; - maxSlider.value = max; - } - - minValue.textContent = `${min} kbps`; - maxValue.textContent = `${max} kbps`; -} - -function toggleQuality(quality) { - const checkbox = document.getElementById(`quality-${quality}-enabled`); - const sliders = document.getElementById(`sliders-${quality}`); - - if (checkbox && sliders) { - if (checkbox.checked) { - sliders.classList.remove('disabled'); - } else { - sliders.classList.add('disabled'); - } - } - - // Also toggle FLAC bit depth selector - if (quality === 'flac') { - const bitDepthSelector = document.getElementById('flac-bit-depth-selector'); - if (bitDepthSelector && checkbox) { - if (checkbox.checked) { - bitDepthSelector.classList.remove('disabled'); - } else { - bitDepthSelector.classList.add('disabled'); - } - } - } - - // Mark preset as custom when manually changing - if (currentQualityProfile) { - currentQualityProfile.preset = 'custom'; - document.querySelectorAll('.preset-button').forEach(btn => { - btn.classList.remove('active'); + currentRankedTargets.forEach((t, i) => { + const row = document.createElement('div'); + row.className = 'ranked-target-row'; + row.draggable = true; + row.dataset.index = String(i); + row.innerHTML = ` + + ${i + 1} + ${rtLabel(t)} + + + + + `; + row.addEventListener('dragstart', e => { + e.dataTransfer.setData('text/plain', String(i)); + row.classList.add('rt-dragging'); }); - } -} - -function setFlacBitDepth(value) { - document.querySelectorAll('.bit-depth-btn').forEach(btn => { - btn.classList.toggle('active', btn.getAttribute('data-value') === value); + row.addEventListener('dragend', () => row.classList.remove('rt-dragging')); + row.addEventListener('dragover', e => { e.preventDefault(); row.classList.add('rt-dragover'); }); + row.addEventListener('dragleave', () => row.classList.remove('rt-dragover')); + row.addEventListener('drop', e => { + e.preventDefault(); + row.classList.remove('rt-dragover'); + const from = parseInt(e.dataTransfer.getData('text/plain'), 10); + if (!Number.isNaN(from) && from !== i) reorderRankedTarget(from, i); + }); + list.appendChild(row); }); +} - // Show/hide fallback toggle — only relevant when a specific bit depth is selected - const fallbackToggle = document.getElementById('flac-fallback-toggle'); - if (fallbackToggle) { - fallbackToggle.style.display = value === 'any' ? 'none' : 'block'; - } - - // Mark preset as custom when manually changing - if (currentQualityProfile) { - currentQualityProfile.preset = 'custom'; - document.querySelectorAll('.preset-button').forEach(btn => { - btn.classList.remove('active'); - }); - } +function markQualityProfileCustom() { + if (currentQualityProfile) currentQualityProfile.preset = 'custom'; + document.querySelectorAll('.preset-button').forEach(btn => btn.classList.remove('active')); +} +function reorderRankedTarget(from, to) { + const [moved] = currentRankedTargets.splice(from, 1); + currentRankedTargets.splice(to, 0, moved); + markQualityProfileCustom(); + renderRankedTargets(); debouncedAutoSaveSettings(); } -function setFlacBitDepthFallback(enabled) { - if (currentQualityProfile) { - currentQualityProfile.preset = 'custom'; - document.querySelectorAll('.preset-button').forEach(btn => { - btn.classList.remove('active'); - }); +function moveRankedTarget(i, dir) { + const j = i + dir; + if (j < 0 || j >= currentRankedTargets.length) return; + [currentRankedTargets[i], currentRankedTargets[j]] = [currentRankedTargets[j], currentRankedTargets[i]]; + markQualityProfileCustom(); + renderRankedTargets(); + debouncedAutoSaveSettings(); +} + +function deleteRankedTarget(i) { + currentRankedTargets.splice(i, 1); + markQualityProfileCustom(); + renderRankedTargets(); + debouncedAutoSaveSettings(); +} + +function onRtAddFormatChange() { + const lossless = document.getElementById('rt-add-format')?.value === 'flac'; + const llFields = document.querySelector('.rt-lossless-fields'); + const lyFields = document.querySelector('.rt-lossy-fields'); + if (llFields) llFields.style.display = lossless ? '' : 'none'; + if (lyFields) lyFields.style.display = lossless ? 'none' : ''; +} + +function addRankedTarget() { + const fmt = document.getElementById('rt-add-format')?.value || 'flac'; + const t = { format: fmt }; + if (fmt === 'flac') { + const bd = document.getElementById('rt-add-bitdepth')?.value; + const sr = document.getElementById('rt-add-samplerate')?.value; + if (bd) t.bit_depth = parseInt(bd, 10); + if (sr) t.min_sample_rate = parseInt(sr, 10); + } else { + const br = document.getElementById('rt-add-bitrate')?.value; + if (br) t.min_bitrate = parseInt(br, 10); } + t.label = rtLabel(t); + currentRankedTargets.push(t); + markQualityProfileCustom(); + renderRankedTargets(); debouncedAutoSaveSettings(); } @@ -2102,45 +2059,23 @@ async function applyQualityPreset(presetName) { } function collectQualityProfileFromUI() { - const profile = { - version: 2, - preset: 'custom', // Will be overridden if a preset is active - qualities: {}, - fallback_enabled: document.getElementById('quality-fallback-enabled')?.checked ?? true - }; - - const qualities = ['flac', 'mp3_320', 'mp3_256', 'mp3_192']; - - qualities.forEach((quality, index) => { - const enabled = document.getElementById(`quality-${quality}-enabled`)?.checked || false; - const minSlider = document.getElementById(`${quality}-min`); - const maxSlider = document.getElementById(`${quality}-max`); - - // Preserve priority from the currently loaded profile instead of using array order - const existingPriority = currentQualityProfile?.qualities?.[quality]?.priority ?? (index + 1); - - profile.qualities[quality] = { - enabled: enabled, - min_kbps: parseInt(minSlider?.value || 0), - max_kbps: parseInt(maxSlider?.value || 99999), - priority: existingPriority - }; - - // Add FLAC-specific bit_depth and fallback settings - if (quality === 'flac') { - const activeBtn = document.querySelector('.bit-depth-btn.active'); - profile.qualities[quality].bit_depth = activeBtn ? activeBtn.getAttribute('data-value') : 'any'; - const fallbackCb = document.getElementById('flac-bit-depth-fallback'); - profile.qualities[quality].bit_depth_fallback = fallbackCb ? fallbackCb.checked : true; - } + // v3: ordered target list. Drop empty/None fields so each target stays + // minimal (matches QualityTarget.to_dict on the backend). + const ranked_targets = currentRankedTargets.map(t => { + const out = { format: t.format }; + if (t.label) out.label = t.label; + if (t.bit_depth) out.bit_depth = t.bit_depth; + if (t.min_sample_rate) out.min_sample_rate = t.min_sample_rate; + if (t.min_bitrate) out.min_bitrate = t.min_bitrate; + return out; }); - // Check if current profile matches a preset - if (currentQualityProfile && currentQualityProfile.preset !== 'custom') { - profile.preset = currentQualityProfile.preset; - } - - return profile; + return { + version: 3, + preset: (currentQualityProfile && currentQualityProfile.preset) || 'custom', + fallback_enabled: document.getElementById('quality-fallback-enabled')?.checked ?? true, + ranked_targets, + }; } async function saveQualityProfile() { diff --git a/webui/static/style.css b/webui/static/style.css index d3a2d09c..7f3cdea5 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -4090,6 +4090,150 @@ body.helper-mode-active #dashboard-activity-feed:hover { } /* Quality tier cards - upgraded inner cards */ +/* ── Ranked-targets editor (v3 quality profile) ───────────────────────── */ +.ranked-targets-editor { + margin-bottom: 16px; +} + +.ranked-targets-label { + display: block; + color: rgba(255, 255, 255, 0.7); + font-size: 11px; + font-weight: 600; + margin-bottom: 8px; +} + +.ranked-targets-list { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 12px; +} + +.ranked-targets-empty { + color: rgba(255, 255, 255, 0.4); + font-size: 11px; + font-style: italic; + padding: 10px 12px; + border: 1px dashed rgba(255, 255, 255, 0.12); + border-radius: 10px; +} + +.ranked-target-row { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.01)); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + transition: border-color 0.15s ease, opacity 0.15s ease; +} + +.ranked-target-row:hover { + border-color: rgba(255, 255, 255, 0.16); +} + +.ranked-target-row.rt-dragging { + opacity: 0.4; +} + +.ranked-target-row.rt-dragover { + border-color: rgba(99, 179, 237, 0.7); +} + +.ranked-target-row .rt-handle { + cursor: grab; + color: rgba(255, 255, 255, 0.35); + font-size: 14px; + user-select: none; +} + +.ranked-target-row .rt-rank { + min-width: 18px; + text-align: center; + color: rgba(255, 255, 255, 0.45); + font-size: 11px; + font-weight: 600; +} + +.ranked-target-row .rt-label { + color: rgba(255, 255, 255, 0.95); + font-size: 12px; + font-weight: 600; +} + +.ranked-target-row .rt-spacer { + flex: 1; +} + +.ranked-target-row .rt-move, +.ranked-target-row .rt-del { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + color: rgba(255, 255, 255, 0.65); + cursor: pointer; + font-size: 11px; + padding: 2px 7px; + transition: background 0.15s ease; +} + +.ranked-target-row .rt-move:hover, +.ranked-target-row .rt-del:hover { + background: rgba(255, 255, 255, 0.12); +} + +.ranked-target-row .rt-del:hover { + background: rgba(229, 62, 62, 0.25); + border-color: rgba(229, 62, 62, 0.5); +} + +.ranked-target-add { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding: 10px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; +} + +.ranked-target-add select, +.ranked-target-add input[type="number"] { + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 6px; + color: rgba(255, 255, 255, 0.9); + font-size: 11px; + padding: 4px 6px; +} + +.ranked-target-add .rt-inline-label { + color: rgba(255, 255, 255, 0.7); + font-size: 11px; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.ranked-target-add .rt-add-btn { + background: rgba(99, 179, 237, 0.18); + border: 1px solid rgba(99, 179, 237, 0.4); + border-radius: 6px; + color: rgba(255, 255, 255, 0.92); + cursor: pointer; + font-size: 11px; + font-weight: 600; + padding: 5px 12px; + transition: background 0.15s ease; +} + +.ranked-target-add .rt-add-btn:hover { + background: rgba(99, 179, 237, 0.3); +} + .quality-tier { margin-bottom: 16px; padding: 12px; From 95a7b51966a66da2b52f80b51b4079714098ea0f Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 14 Jun 2026 12:46:39 +0200 Subject: [PATCH 08/77] test(quality): guard reason content, force_import isolation, bitrate-as-threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quality guard: rejects with a 'file is X, wanted Y' reason (the string the track-detail modal surfaces), accepts when a target is met or fallback is on, skips when unprobeable. force_import isolation: the 'quality' bypass must not skip the AcoustID check and vice-versa; a quality reject persists trigger='quality' (not 'acoustid') in the sidecar — so a quality mismatch never routes through the force_import path (reserved for AcoustID version-mismatch). Model: lossy matches a MINIMUM bitrate (>=, a range); lossless matches on bit depth + sample rate, never exact bitrate, so a FLAC's varying bitrate (mono / compression) can't falsely reject it. v2->v3 migration preserves order. 47 passing across the quality + guard suites. Co-Authored-By: Claude Opus 4.8 --- tests/imports/test_quality_guard.py | 109 ++++++++++++++++++++++++++++ tests/quality/test_model.py | 73 +++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 tests/imports/test_quality_guard.py create mode 100644 tests/quality/test_model.py diff --git a/tests/imports/test_quality_guard.py b/tests/imports/test_quality_guard.py new file mode 100644 index 00000000..b180bf75 --- /dev/null +++ b/tests/imports/test_quality_guard.py @@ -0,0 +1,109 @@ +"""Quality guard + quarantine isolation. + +Locks two invariants the global-quality work depends on: + +1. ``check_quality_target`` rejects with a 'what the file IS vs what was + WANTED' reason (surfaced in the track-detail modal), and accepts when a + target is met or fallback is on. +2. A quality mismatch is isolated from the AcoustID/force_import path: it + uses ``trigger='quality'`` and the ``'quality'`` bypass flag, which must + NOT bypass the AcoustID check and vice-versa. force_imported stays + reserved for AcoustID version-mismatch acceptance. +""" + +import json +import types + +import pytest + +import core.imports.guards as guards +import core.imports.file_ops as file_ops +from core.imports.pipeline import _should_skip_quarantine_check +from core.quality.model import AudioQuality + + +class _FakeDB: + def __init__(self, profile): + self._p = profile + + def get_quality_profile(self): + return self._p + + +def _patch_guard(monkeypatch, probe_aq, profile, downsample=False): + monkeypatch.setattr(file_ops, 'probe_audio_quality', lambda fp: probe_aq) + monkeypatch.setattr(guards, 'MusicDatabase', lambda: _FakeDB(profile)) + monkeypatch.setattr( + guards, '_get_config_manager', + lambda: types.SimpleNamespace(get=lambda k, d=None: downsample if 'downsample' in k else d), + ) + + +_WANT_FLAC24 = { + 'fallback_enabled': False, + 'ranked_targets': [ + {'label': 'FLAC 24-bit/96kHz', 'format': 'flac', 'bit_depth': 24, 'min_sample_rate': 96000}, + ], +} +_WANT_FLAC24_FALLBACK = {**_WANT_FLAC24, 'fallback_enabled': True} + + +# ── check_quality_target ─────────────────────────────────────────────────── + +def test_rejects_with_wanted_vs_got_reason(monkeypatch): + _patch_guard(monkeypatch, AudioQuality('flac', sample_rate=44100, bit_depth=16), _WANT_FLAC24) + reason = guards.check_quality_target('/x/song.flac', {}) + assert reason is not None + assert 'FLAC 16-bit' in reason # what the file IS + assert 'FLAC 24-bit/96kHz' in reason # what was WANTED + + +def test_accepts_when_target_met(monkeypatch): + _patch_guard(monkeypatch, AudioQuality('flac', sample_rate=96000, bit_depth=24), _WANT_FLAC24) + assert guards.check_quality_target('/x/song.flac', {}) is None + + +def test_accepts_via_fallback(monkeypatch): + _patch_guard(monkeypatch, AudioQuality('flac', sample_rate=44100, bit_depth=16), _WANT_FLAC24_FALLBACK) + assert guards.check_quality_target('/x/song.flac', {}) is None + + +def test_skips_when_unprobeable(monkeypatch): + _patch_guard(monkeypatch, None, _WANT_FLAC24) + assert guards.check_quality_target('/x/song.flac', {}) is None + + +# ── force_import isolation ───────────────────────────────────────────────── + +def test_quality_bypass_does_not_skip_acoustid(): + ctx = {'_skip_quarantine_check': 'quality'} + assert _should_skip_quarantine_check(ctx, 'quality') is True + assert _should_skip_quarantine_check(ctx, 'acoustid') is False + + +def test_acoustid_bypass_does_not_skip_quality(): + ctx = {'_skip_quarantine_check': 'acoustid'} + assert _should_skip_quarantine_check(ctx, 'acoustid') is True + assert _should_skip_quarantine_check(ctx, 'quality') is False + + +def test_quality_quarantine_persists_quality_trigger(monkeypatch, tmp_path): + # A quality reject writes trigger='quality' (not 'acoustid') into the + # sidecar, so Approve never routes it through the force_import path. + monkeypatch.setattr( + guards, '_get_config_manager', + lambda: types.SimpleNamespace(get=lambda k, d=None: str(tmp_path) if 'download_path' in k else d), + ) + src = tmp_path / 'song.flac' + src.write_bytes(b'FLACfake') + qpath = guards.move_to_quarantine( + str(src), {}, 'Quality mismatch: file is FLAC 16-bit, wanted FLAC 24-bit/96kHz', + automation_engine=None, trigger='quality', + ) + sidecars = list((tmp_path / 'ss_quarantine').glob('*.json')) + assert len(sidecars) == 1 + meta = json.loads(sidecars[0].read_text(encoding='utf-8')) + assert meta['trigger'] == 'quality' + assert meta['trigger'] != 'acoustid' + assert 'wanted FLAC 24-bit/96kHz' in meta['quarantine_reason'] + assert qpath.endswith('.quarantined') diff --git a/tests/quality/test_model.py b/tests/quality/test_model.py new file mode 100644 index 00000000..62e5887a --- /dev/null +++ b/tests/quality/test_model.py @@ -0,0 +1,73 @@ +"""AudioQuality.matches_target + v2->v3 migration. + +Locks the bitrate-as-threshold behaviour: lossy formats match on a MINIMUM +bitrate (>=, a range), and lossless matches on bit depth + sample rate — NOT +on exact bitrate, so a FLAC's wildly-varying bitrate (stereo vs mono, FLAC +compression) never falsely rejects it. +""" + +import pytest + +from core.quality.model import ( + AudioQuality, + QualityTarget, + v2_qualities_to_ranked_targets, +) + + +# ── lossy: bitrate is a minimum threshold (a range), never exact ─────────── + +def test_mp3_meets_minimum_bitrate(): + t = QualityTarget(format='mp3', min_bitrate=320) + assert AudioQuality('mp3', bitrate=320).matches_target(t) is True + assert AudioQuality('mp3', bitrate=400).matches_target(t) is True # above floor ok + + +def test_mp3_below_minimum_bitrate_rejected(): + t = QualityTarget(format='mp3', min_bitrate=320) + assert AudioQuality('mp3', bitrate=300).matches_target(t) is False + + +def test_mp3_matches_lower_threshold(): + t = QualityTarget(format='mp3', min_bitrate=192) + assert AudioQuality('mp3', bitrate=320).matches_target(t) is True + + +# ── lossless: matched on bit depth + sample rate, NOT exact bitrate ──────── + +def test_flac_matches_on_depth_and_rate_regardless_of_bitrate(): + t = QualityTarget(format='flac', bit_depth=24, min_sample_rate=96000) + # An unusual/low bitrate (e.g. a mono or highly-compressed FLAC) must + # still match when bit depth + sample rate satisfy the target. + weird = AudioQuality('flac', bitrate=300, sample_rate=96000, bit_depth=24) + assert weird.matches_target(t) is True + + +def test_flac_below_target_sample_rate_rejected(): + t = QualityTarget(format='flac', bit_depth=24, min_sample_rate=96000) + assert AudioQuality('flac', sample_rate=44100, bit_depth=24).matches_target(t) is False + + +def test_flac_below_target_bit_depth_rejected(): + t = QualityTarget(format='flac', bit_depth=24) + assert AudioQuality('flac', sample_rate=96000, bit_depth=16).matches_target(t) is False + + +def test_format_mismatch_rejected(): + t = QualityTarget(format='flac', bit_depth=16) + assert AudioQuality('mp3', bitrate=320).matches_target(t) is False + + +# ── v2 -> v3 migration preserves the user's priority order ───────────────── + +def test_v2_to_v3_preserves_order_and_maps_fields(): + qualities = { + 'flac': {'enabled': True, 'priority': 1, 'bit_depth': '24'}, + 'mp3_320': {'enabled': True, 'priority': 2}, + 'mp3_192': {'enabled': False, 'priority': 3}, # disabled → dropped + } + targets = v2_qualities_to_ranked_targets(qualities) + formats = [t['format'] for t in targets] + assert formats == ['flac', 'mp3'] # disabled mp3_192 omitted + assert targets[0]['bit_depth'] == 24 + assert targets[1]['min_bitrate'] == 320 From d717f06afe727da9f93560dcaac5f6e13570be50 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 14 Jun 2026 12:52:14 +0200 Subject: [PATCH 09/77] test: update bit-depth guard tests to the unified quality-guard behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_flac_bit_depth now delegates to check_quality_target, which probes the real file and treats bit depth as a MINIMUM (24-bit satisfies a 16-bit target) — the old context-string parsing, per-quality bit_depth_fallback, and 'reject higher bit depth' semantics are gone. Rewrite the wrapper tests to the probe-based model and update the rejection-reason assertion to the unified 'quality filter' wording. Co-Authored-By: Claude Opus 4.8 --- tests/imports/test_import_guards.py | 70 +++++++++++-------- tests/imports/test_import_rejection_reason.py | 4 +- 2 files changed, 42 insertions(+), 32 deletions(-) diff --git a/tests/imports/test_import_guards.py b/tests/imports/test_import_guards.py index ee71a1dd..44cd5bde 100644 --- a/tests/imports/test_import_guards.py +++ b/tests/imports/test_import_guards.py @@ -1,47 +1,55 @@ +"""``check_flac_bit_depth`` is a thin legacy wrapper that delegates to the +unified ``check_quality_target`` guard, which probes the REAL file (mutagen) +and ranks it against the v3 ranked-targets list. + +The old per-quality ``bit_depth_fallback`` config and the "reject a higher bit +depth" semantics are gone by design: bit depth is now a MINIMUM, so a 24-bit +file satisfies a 16-bit target. These tests pin the wrapper's current +behaviour (deeper coverage of ``check_quality_target`` lives in +``tests/imports/test_quality_guard.py``). +""" + from types import SimpleNamespace -from core.imports import guards +import core.imports.guards as guards +import core.imports.file_ops as file_ops +from core.quality.model import AudioQuality class _FakeDB: - def __init__(self, quality_profile): - self._quality_profile = quality_profile + def __init__(self, profile): + self._profile = profile def get_quality_profile(self): - return self._quality_profile + return self._profile -def test_check_flac_bit_depth_rejects_strict_mismatch(monkeypatch): +_WANT_FLAC24 = { + "fallback_enabled": False, + "ranked_targets": [ + {"label": "FLAC 24-bit/96kHz", "format": "flac", "bit_depth": 24, "min_sample_rate": 96000}, + ], +} + + +def _patch(monkeypatch, aq, profile): + monkeypatch.setattr(file_ops, "probe_audio_quality", lambda fp: aq) + monkeypatch.setattr(guards, "MusicDatabase", lambda: _FakeDB(profile)) monkeypatch.setattr( - guards, - "MusicDatabase", - lambda: _FakeDB({"qualities": {"flac": {"bit_depth": "16", "bit_depth_fallback": False}}}), - ) - monkeypatch.setattr( - guards, - "_get_config_manager", + guards, "_get_config_manager", lambda: SimpleNamespace(get=lambda _key, default=None: False), ) - context = {"_audio_quality": "FLAC 24bit", "track_info": {"name": "Song One"}} - assert guards.check_flac_bit_depth("/tmp/Song One.flac", context) == ( - "FLAC bit depth mismatch: file is 24-bit, preference is 16-bit" - ) +def test_check_flac_bit_depth_rejects_below_target(monkeypatch): + # 16-bit file, target wants 24-bit, fallback off → rejected. + _patch(monkeypatch, AudioQuality("flac", sample_rate=44100, bit_depth=16), _WANT_FLAC24) + reason = guards.check_flac_bit_depth("/tmp/Song One.flac", {}) + assert reason is not None + assert "FLAC 24-bit/96kHz" in reason -def test_check_flac_bit_depth_allows_fallback_when_enabled(monkeypatch): - monkeypatch.setattr( - guards, - "MusicDatabase", - lambda: _FakeDB({"qualities": {"flac": {"bit_depth": "16", "bit_depth_fallback": True}}}), - ) - monkeypatch.setattr( - guards, - "_get_config_manager", - lambda: SimpleNamespace(get=lambda _key, default=None: False), - ) - - context = {"_audio_quality": "FLAC 24bit", "track_info": {"name": "Song One"}} - - assert guards.check_flac_bit_depth("/tmp/Song One.flac", context) is None +def test_check_flac_bit_depth_accepts_when_meeting_target(monkeypatch): + # 24-bit/96k file meets the 24-bit target → accepted. + _patch(monkeypatch, AudioQuality("flac", sample_rate=96000, bit_depth=24), _WANT_FLAC24) + assert guards.check_flac_bit_depth("/tmp/Song One.flac", {}) is None diff --git a/tests/imports/test_import_rejection_reason.py b/tests/imports/test_import_rejection_reason.py index 9377ff6a..4d630cde 100644 --- a/tests/imports/test_import_rejection_reason.py +++ b/tests/imports/test_import_rejection_reason.py @@ -48,9 +48,11 @@ def test_acoustid_quarantine_without_message_still_flags(): def test_bitdepth_rejection_detected(): + # The _bitdepth_rejected flag now signals any quality-target rejection + # (bit depth, sample rate, format, bitrate) — the unified quality guard. reason = import_rejection_reason({'_bitdepth_rejected': True}) assert reason is not None - assert 'bit-depth' in reason.lower() + assert 'quality filter' in reason.lower() def test_race_guard_failure_detected(): From 6046a814cb8a4611992f50a185cad683c7250d6b Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 14 Jun 2026 13:02:22 +0200 Subject: [PATCH 10/77] fix(ui): un-gate global quality profile; make unverified rows clickable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quality Profile is now a global system driving every source, so stop hiding it behind Soulseek being active — show it on the downloads tab regardless. On the review queue, make Unverified rows row-clickable to open the audit/ info modal (matching Quarantine rows, which were already clickable); the action buttons stopPropagation so they don't double-trigger. Co-Authored-By: Claude Opus 4.8 --- webui/index.html | 2 +- webui/static/pages-extra.js | 11 ++++++++++- webui/static/settings.js | 12 ++++++------ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/webui/index.html b/webui/index.html index e82b0577..d9137468 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5064,7 +5064,7 @@
+
${extraAction}
`; } diff --git a/webui/static/style.css b/webui/static/style.css index 468276d3..4f2944cb 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -68216,6 +68216,7 @@ body.em-scroll-lock { overflow: hidden; } .verif-bulk-danger { border-color: rgba(248,113,113,0.4) !important; color: #f87171 !important; } .verif-quar-alt-wrapper { display: contents; } +.verif-quar-alt-slot { min-width: 68px; display: flex; align-items: center; justify-content: flex-end; flex-shrink: 0; } .verif-quar-alt-btn { border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.5); border-radius: 6px; padding: 2px 7px; font-size: 11px; cursor: pointer; white-space: nowrap; line-height: 18px; } .verif-quar-alt-btn:hover { background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.8); } .verif-quar-alt-btn.open { color: rgba(255,255,255,0.75); border-color: rgba(255,255,255,0.25); } From 94637cbe6f56c1522e7f5148f77de8854d0d0d32 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 24 Jun 2026 15:45:39 +0200 Subject: [PATCH 63/77] fix(quality-upgrade): ranking-based top-target check via rank_candidate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With require_top_target=True the old code used check_targets = targets[:1] plus quality_meets_profile — which fell back to ALL targets when only one target was configured, so single-target profiles (FLAC (any) or FLAC 24-bit alone) never flagged anything. Replace with a direct rank_candidate(measured_aq, targets) call: skip only when idx==0 (file already at rank 0, the top tier). Any lower rank (or no matching rank) is flagged for upgrade search. This is fully profile-driven: a library of 16-bit FLACs against a [24-bit/192, 24-bit/96, 24-bit/44, 16-bit] ranking will flag the 16-bit files; a library already at 24-bit/192 won't. Co-Authored-By: Claude Sonnet 4.6 --- core/repair_jobs/quality_upgrade.py | 42 ++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/core/repair_jobs/quality_upgrade.py b/core/repair_jobs/quality_upgrade.py index 00af7cf1..7d6a9b44 100644 --- a/core/repair_jobs/quality_upgrade.py +++ b/core/repair_jobs/quality_upgrade.py @@ -43,6 +43,7 @@ from core.library.path_resolver import resolve_library_file_path # the SAME definition the download import guard uses. Module-level so they're # monkeypatchable in tests. from core.imports.file_ops import probe_audio_quality +from core.quality.model import rank_candidate from core.quality.selection import targets_from_profile, quality_meets_profile from utils.logging_config import get_logger @@ -566,11 +567,11 @@ class QualityUpgradeJob(RepairJob): logger.info("[Quality Upgrade] No quality targets in profile — nothing to flag") return result - # require_top_target: only the highest-priority target (targets[0]) counts - # as "good enough". A 16-bit FLAC is flagged even when 16-bit is an accepted - # fallback, because the user's preferred quality is 24-bit. Default off so - # existing behaviour (any target satisfied = skip) is unchanged. - check_targets = targets[:1] if require_top and len(targets) > 1 else targets + logger.info( + "[Quality Upgrade] scope=%s require_top=%s · all targets: %s", + scope, require_top, + [t.label for t in targets] if targets else '(none — all pass)', + ) try: tracks = self._load_tracks(db, scope) @@ -648,11 +649,21 @@ class QualityUpgradeJob(RepairJob): context.update_progress(i + 1, total) continue - if not broken_reason and measured_aq is not None and quality_meets_profile(measured_aq, check_targets): - result.skipped += 1 - if context.update_progress and (i + 1) % 25 == 0: - context.update_progress(i + 1, total) - continue + if not broken_reason and measured_aq is not None: + if require_top: + # ranking-based: skip only if the file already sits at rank 0 + # (the top configured target). Any lower rank → flag for upgrade. + idx, _ = rank_candidate(measured_aq, targets) + already_best = (idx == 0) + else: + # default: skip if the file meets ANY configured target (i.e. + # it's not below the acceptable floor). + already_best = quality_meets_profile(measured_aq, targets) + if already_best: + result.skipped += 1 + if context.update_progress and (i + 1) % 25 == 0: + context.update_progress(i + 1, total) + continue # Below profile (or broken) — find a better version to propose. if engine is None: @@ -754,7 +765,7 @@ class QualityUpgradeJob(RepairJob): title=f'Upgrade: {artist_name} - {title} ({current_label})', description=( f'"{title}" by {artist_name} is {current_label}' - + (f', below your preferred quality ({targets[0].label})' if require_top and len(targets) > 1 else ', below your preferred quality') + + (f', below your preferred quality ({targets[0].label})' if require_top and targets else ', below your preferred quality') + f'. Best match: "{_track_name(best)}" via {source} ' f'({_MATCH_NOTE.get(matched_via, "matched") if matched_via != "search" else f"confidence {conf:.0%}"}). ' 'Apply to add it to the wishlist.'), @@ -784,6 +795,13 @@ class QualityUpgradeJob(RepairJob): if context.update_progress: context.update_progress(total, total) - logger.info("[Quality Upgrade] %d scanned, %d upgrades found, %d met/skip", + logger.info("[Quality Upgrade] %d scanned, %d upgrades found, %d already met profile / skipped", result.scanned, result.findings_created, result.skipped) + if result.scanned > 0 and result.findings_created == 0 and result.errors == 0: + logger.info( + "[Quality Upgrade] All tracks already satisfy the configured targets. " + "If you expected upgrades, check your quality profile — the current " + "top target is: %s", + targets[0].label if targets else '(none)', + ) return result From ff12d8bbf207e6d78d7ff94264dc049909f5ec22 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 24 Jun 2026 15:59:25 +0200 Subject: [PATCH 64/77] fix(repair-jobs): boolean settings saved as string 'true'/'false' by UI dropdown HTML - - - + + + + + + + + + + + + diff --git a/webui/static/style.css b/webui/static/style.css index 4f2944cb..8cf81093 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -4226,8 +4226,25 @@ body.helper-mode-active #dashboard-activity-feed:hover { border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 6px; color: rgba(255, 255, 255, 0.9); - font-size: 11px; - padding: 4px 6px; + font-size: 13px; + padding: 7px 9px; + min-height: 34px; +} + +.ranked-target-add select { min-width: 130px; cursor: pointer; } + +/* Kill the default light optgroup/option bars in the dark dropdown. */ +.ranked-target-add select optgroup { + background: #1a1a1e; + color: rgba(255, 255, 255, 0.55); + font-style: normal; + font-weight: 600; +} + +.ranked-target-add select option { + background: #1a1a1e; + color: rgba(255, 255, 255, 0.92); + font-weight: 400; } .ranked-target-add .rt-inline-label { From 551d12dba3830c0d49370343d5b9c678d51b3902 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 24 Jun 2026 23:24:28 +0200 Subject: [PATCH 74/77] feat(ui): add "All lossless / All lossy" group entries to ranked targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convenience: pick a group + constraints (e.g. All lossless, ≥24-bit/≥96kHz) and it expands into one concrete per-format target each (FLAC/ALAC/WAV, or the five lossy formats) at that slot — so you don't add them one by one. Purely UI; the backend still ranks concrete per-format targets. Re-adding a group skips formats that already have an identical target, and the expanded entries can be reordered/pruned individually afterwards. Co-Authored-By: Claude Opus 4.8 --- webui/index.html | 2 ++ webui/static/settings.js | 41 +++++++++++++++++++++++++++++++--------- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/webui/index.html b/webui/index.html index d61341c8..29d110d7 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5058,11 +5058,13 @@
- + - + From df4ef9938980de62e0ffe9586323c97251e5c846 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 24 Jun 2026 23:30:27 +0200 Subject: [PATCH 76/77] =?UTF-8?q?feat(ui):=20add=20a=20Custom=E2=80=A6=20o?= =?UTF-8?q?ption=20for=20manual=20lossy=20bitrate=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keeps the reference presets (96/128/192/256/320) but adds "Custom…", which reveals a number input so you can type any minimum bitrate. addRankedTarget reads the manual value when the dropdown is on Custom. Co-Authored-By: Claude Opus 4.8 --- webui/index.html | 7 ++++++- webui/static/settings.js | 12 +++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/webui/index.html b/webui/index.html index 64883e5e..2d0e6fe3 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5087,14 +5087,19 @@
diff --git a/webui/static/settings.js b/webui/static/settings.js index 94e7dee8..82ae3c3d 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -2085,6 +2085,15 @@ function onRtAddFormatChange() { const lyFields = document.querySelector('.rt-lossy-fields'); if (llFields) llFields.style.display = lossless ? '' : 'none'; if (lyFields) lyFields.style.display = lossless ? 'none' : ''; + onRtBitrateChange(); // keep the custom-bitrate field in sync +} + +// Reveal the manual bitrate input only when the dropdown is on "Custom…". +function onRtBitrateChange() { + const wrap = document.getElementById('rt-add-bitrate-custom-wrap'); + if (!wrap) return; + const isCustom = document.getElementById('rt-add-bitrate')?.value === 'custom'; + wrap.style.display = isCustom ? '' : 'none'; } function addRankedTarget() { @@ -2098,7 +2107,8 @@ function addRankedTarget() { if (bd) constraints.bit_depth = parseInt(bd, 10); if (sr) constraints.min_sample_rate = parseInt(sr, 10); } else { - const br = document.getElementById('rt-add-bitrate')?.value; + let br = document.getElementById('rt-add-bitrate')?.value; + if (br === 'custom') br = document.getElementById('rt-add-bitrate-custom')?.value; if (br) constraints.min_bitrate = parseInt(br, 10); } From 81a8b57dba191bdd3599a4e97a5d2d0b9b65b126 Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 25 Jun 2026 00:04:16 +0200 Subject: [PATCH 77/77] feat(ui): show download source on quarantine rows (like Completed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quarantine rows now display the download service (HiFi / Soulseek / Tidal …) on a third line, matching the Completed view's source line. Derived from the entry's source_username (a streaming service name passes through; a Soulseek uploader/peer collapses to "Soulseek") and rendered with the same adl-row-batch styling + _adlSourceLabel mapping the Completed rows use. Co-Authored-By: Claude Opus 4.8 --- webui/static/pages-extra.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index 5093600f..572f4639 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -2669,9 +2669,21 @@ const _VERIF_QUAR_TRIGGERS = { bit_depth: ['BIT DEPTH FILTER', 'verif-rb-int'], }; +// Streaming sources carry their service name in source_username; a Soulseek +// download carries the uploader's peer name instead — collapse that to +// 'soulseek' so the label matches the Completed view's download-source line. +const _VERIF_QUAR_STREAMING_SOURCES = ['youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud', 'amazon', 'torrent', 'usenet']; + +function _verifQuarSourceLabel(q) { + const u = String(q.source_username || '').toLowerCase(); + if (_VERIF_QUAR_STREAMING_SOURCES.includes(u)) return _adlSourceLabel(u); + return q.source_username ? _adlSourceLabel('soulseek') : ''; +} + function _verifQuarRowHtml(q, idx, extraAction = '') { const title = _adlEsc(q.expected_track || q.original_filename || q.filename || 'Unknown file'); const meta = [_adlEsc(q.expected_artist || ''), _adlEsc(q.original_filename || '')].filter(Boolean).join(' — '); + const sourceLabel = _verifQuarSourceLabel(q); const [trigLabel, trigClass] = _VERIF_QUAR_TRIGGERS[q.trigger] || ['QUARANTINED', 'verif-rb-unv']; const timeAgo = _verifTimeAgo(q.timestamp); const approveBtn = q.has_full_context @@ -2692,6 +2704,7 @@ function _verifQuarRowHtml(q, idx, extraAction = '') {
${title}
${meta ? `
${meta}
` : ''} + ${sourceLabel ? `
${sourceLabel}
` : ''}
${details || 'No further details in the sidecar.'}