From 2c91af006234450e7d2ac9f742282d63939fc315 Mon Sep 17 00:00:00 2001 From: nick2000713 Date: Sat, 13 Jun 2026 22:22:08 +0200 Subject: [PATCH 01/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] =?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/96] 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.'}
From 729a06c6d7b36b69ac2960659b0dbbbe8d72c162 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 24 Jun 2026 21:00:32 -0700 Subject: [PATCH 78/96] Download clients: don't crash init when the download path can't be created MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SoundCloud/Amazon/Tidal/Qobuz/Deezer/HiFi/Lidarr clients did an UNGUARDED mkdir(parents=True) on the configured download path in __init__. With a Docker '/app' path (or any unmounted/misconfigured volume), that raises Permission Denied, the plugin registry nulls the whole client, and the source vanishes — SoulseekClient already guards the identical mkdir and just warns. Outside the container this also failed every test_download_orchestrator_soundcloud.py test (10) by leaving client('soundcloud') = None for the patch targets. Fix: wrap the mkdir in try/except OSError + warn (matching soulseek) across all seven clients and the orchestrator's runtime path-update; the dir is created lazily at download time. Real robustness win: a slow/unmounted volume at boot no longer silently drops download sources. Regression test forces an uncreatable path and asserts init doesn't raise — pinned in any environment. Full suite green: 6713 passed, 0 failed (was 10 failed). --- core/amazon_download_client.py | 5 ++++- core/deezer_download_client.py | 5 ++++- core/download_orchestrator.py | 5 ++++- core/hifi_client.py | 5 ++++- core/lidarr_download_client.py | 5 ++++- core/qobuz_client.py | 5 ++++- core/soundcloud_client.py | 9 ++++++++- core/tidal_download_client.py | 5 ++++- tests/test_soundcloud_client.py | 16 ++++++++++++++++ 9 files changed, 52 insertions(+), 8 deletions(-) diff --git a/core/amazon_download_client.py b/core/amazon_download_client.py index 004a0e8d..7b0c398d 100644 --- a/core/amazon_download_client.py +++ b/core/amazon_download_client.py @@ -78,7 +78,10 @@ class AmazonDownloadClient(DownloadSourcePlugin): if download_path is None: download_path = config_manager.get("soulseek.download_path", "./downloads") self.download_path = Path(download_path) - self.download_path.mkdir(parents=True, exist_ok=True) + try: + self.download_path.mkdir(parents=True, exist_ok=True) + except OSError as e: + logger.warning(f"Could not verify download path {self.download_path}: {e}") self._quality = quality_tier_for_source("amazon", default="flac") self._allow_fallback = config_manager.get("amazon_download.allow_fallback", True) diff --git a/core/deezer_download_client.py b/core/deezer_download_client.py index f2368ac1..e1830f64 100644 --- a/core/deezer_download_client.py +++ b/core/deezer_download_client.py @@ -93,7 +93,10 @@ class DeezerDownloadClient(DownloadSourcePlugin): if download_path is None: download_path = config_manager.get('soulseek.download_path', './downloads') self.download_path = Path(download_path) - self.download_path.mkdir(parents=True, exist_ok=True) + try: + self.download_path.mkdir(parents=True, exist_ok=True) + except OSError as e: + logger.warning(f"Could not verify download path {self.download_path}: {e}") # Engine reference is populated by set_engine() at registration # time. None until orchestrator wires the registry. diff --git a/core/download_orchestrator.py b/core/download_orchestrator.py index 664a0f4f..5e08b496 100644 --- a/core/download_orchestrator.py +++ b/core/download_orchestrator.py @@ -143,7 +143,10 @@ class DownloadOrchestrator: continue if hasattr(client, 'download_path') and client.download_path != new_path: client.download_path = new_path - client.download_path.mkdir(parents=True, exist_ok=True) + try: + client.download_path.mkdir(parents=True, exist_ok=True) + except OSError as e: + logger.warning(f"Could not verify download path {new_path}: {e}") # YouTube also caches path in yt-dlp opts if hasattr(client, 'download_opts') and 'outtmpl' in client.download_opts: client.download_opts['outtmpl'] = str(new_path / '%(title)s.%(ext)s') diff --git a/core/hifi_client.py b/core/hifi_client.py index 73ce0c69..f103e136 100644 --- a/core/hifi_client.py +++ b/core/hifi_client.py @@ -257,7 +257,10 @@ class HiFiClient(DownloadSourcePlugin): if download_path is None: download_path = config_manager.get('soulseek.download_path', './downloads') self.download_path = Path(download_path) - self.download_path.mkdir(parents=True, exist_ok=True) + try: + self.download_path.mkdir(parents=True, exist_ok=True) + except OSError as e: + logger.warning(f"Could not verify download path {self.download_path}: {e}") self._instances = [] self._instance_lock = threading.Lock() diff --git a/core/lidarr_download_client.py b/core/lidarr_download_client.py index 52bee817..578ce52d 100644 --- a/core/lidarr_download_client.py +++ b/core/lidarr_download_client.py @@ -47,7 +47,10 @@ class LidarrDownloadClient(DownloadSourcePlugin): if download_path is None: download_path = config_manager.get('soulseek.download_path', './downloads') self.download_path = Path(download_path) - self.download_path.mkdir(parents=True, exist_ok=True) + try: + self.download_path.mkdir(parents=True, exist_ok=True) + except OSError as e: + logger.warning(f"Could not verify download path {self.download_path}: {e}") self.active_downloads: Dict[str, Dict[str, Any]] = {} self._download_lock = threading.Lock() diff --git a/core/qobuz_client.py b/core/qobuz_client.py index 73a0cae1..1c7c3b6f 100644 --- a/core/qobuz_client.py +++ b/core/qobuz_client.py @@ -120,7 +120,10 @@ class QobuzClient(DownloadSourcePlugin): download_path = config_manager.get('soulseek.download_path', './downloads') self.download_path = Path(download_path) - self.download_path.mkdir(parents=True, exist_ok=True) + try: + self.download_path.mkdir(parents=True, exist_ok=True) + except OSError as e: + logger.warning(f"Could not verify download path {self.download_path}: {e}") logger.info(f"Qobuz client using download path: {self.download_path}") diff --git a/core/soundcloud_client.py b/core/soundcloud_client.py index caf25ce7..867e4fa0 100644 --- a/core/soundcloud_client.py +++ b/core/soundcloud_client.py @@ -128,7 +128,14 @@ class SoundcloudClient(DownloadSourcePlugin): download_path = config_manager.get('soulseek.download_path', './downloads') self.download_path = Path(download_path) - self.download_path.mkdir(parents=True, exist_ok=True) + # Don't crash construction if the path isn't creatable yet (e.g. an + # unmounted/misconfigured volume) — the registry would null the whole + # client and the source vanishes. Warn and continue, same as + # SoulseekClient; the dir is (re)created lazily at download time. + try: + self.download_path.mkdir(parents=True, exist_ok=True) + except OSError as e: + logger.warning(f"Could not verify SoundCloud download path {self.download_path}: {e}") logger.info(f"SoundCloud client using download path: {self.download_path}") diff --git a/core/tidal_download_client.py b/core/tidal_download_client.py index 5e2f4524..89757d42 100644 --- a/core/tidal_download_client.py +++ b/core/tidal_download_client.py @@ -120,7 +120,10 @@ class TidalDownloadClient(DownloadSourcePlugin): download_path = config_manager.get('soulseek.download_path', './downloads') self.download_path = Path(download_path) - self.download_path.mkdir(parents=True, exist_ok=True) + try: + self.download_path.mkdir(parents=True, exist_ok=True) + except OSError as e: + logger.warning(f"Could not verify download path {self.download_path}: {e}") logger.info(f"Tidal download client using download path: {self.download_path}") diff --git a/tests/test_soundcloud_client.py b/tests/test_soundcloud_client.py index 70e5afb3..4e0aa852 100644 --- a/tests/test_soundcloud_client.py +++ b/tests/test_soundcloud_client.py @@ -846,3 +846,19 @@ def test_live_download_a_known_public_track(tmp_dl: Path) -> None: assert final_path is not None assert os.path.exists(final_path) assert os.path.getsize(final_path) > 100 * 1024 + + +# ── construction robustness: an uncreatable download path must not crash init ── +# Regression: the download path is read from config (often a Docker /app path). If +# mkdir fails (unmounted/misconfigured volume, or running outside the container), +# the client must warn and continue — NOT raise. An unguarded mkdir made the +# registry null the whole client, dropping SoundCloud as a source entirely (and +# failing every orchestrator-soundcloud test outside Docker). +def test_init_survives_uncreatable_download_path(tmp_path): + from core.soundcloud_client import SoundcloudClient + blocker = tmp_path / "iam_a_file" + blocker.write_text("x") + bad_path = str(blocker / "subdir") # mkdir under a file -> NotADirectoryError (OSError) + client = SoundcloudClient(download_path=bad_path) # must not raise + assert client is not None + assert str(client.download_path) == bad_path From ad657f02a88c49725268781d0f5a00ed8c20a4c4 Mon Sep 17 00:00:00 2001 From: ramonskie Date: Thu, 25 Jun 2026 13:43:24 +0200 Subject: [PATCH 79/96] Fix playlist sync status labels --- tests/test_playlist_sync_status.py | 65 ++++++++++++++++++++++++++++++ web_server.py | 59 ++++++++++++++++++++++++--- webui/static/sync-spotify.js | 2 +- 3 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 tests/test_playlist_sync_status.py diff --git a/tests/test_playlist_sync_status.py b/tests/test_playlist_sync_status.py new file mode 100644 index 00000000..317b8a6f --- /dev/null +++ b/tests/test_playlist_sync_status.py @@ -0,0 +1,65 @@ +from datetime import datetime, timedelta + +from web_server import ( + _format_playlist_sync_status, + _resolve_spotify_playlist_sync_status, +) + + +class _StubDatabase: + def __init__(self, mirrored): + self._mirrored = mirrored + + def get_mirrored_playlist_by_source(self, source, source_playlist_id, profile_id): + assert source == 'spotify' + assert source_playlist_id == 'spotify-playlist-1' + assert profile_id == 7 + return self._mirrored + + +def _status(minutes_ago=0, **overrides): + timestamp = (datetime(2026, 6, 25, 12, 0, 0) - timedelta(minutes=minutes_ago)).isoformat() + return {'last_synced': timestamp, **overrides} + + +def test_resolve_spotify_playlist_sync_status_uses_mirrored_auto_sync_status(): + sync_statuses = { + 'auto_mirror_42': _status(matched_tracks=12), + } + + status = _resolve_spotify_playlist_sync_status( + 'spotify-playlist-1', + sync_statuses, + database=_StubDatabase({'id': 42}), + profile_id=7, + ) + + assert status['matched_tracks'] == 12 + + +def test_resolve_spotify_playlist_sync_status_prefers_newest_status(): + sync_statuses = { + 'spotify-playlist-1': _status(minutes_ago=20, matched_tracks=1), + 'auto_mirror_42': _status(minutes_ago=5, matched_tracks=2), + } + + status = _resolve_spotify_playlist_sync_status( + 'spotify-playlist-1', + sync_statuses, + database=_StubDatabase({'id': 42}), + profile_id=7, + ) + + assert status['matched_tracks'] == 2 + + +def test_format_playlist_sync_status_treats_missing_snapshot_as_synced(): + status = _status() + + assert _format_playlist_sync_status(status, 'current-snapshot') == 'Synced: Jun 25, 12:00' + + +def test_format_playlist_sync_status_marks_snapshot_mismatch_as_last_sync(): + status = _status(snapshot_id='old-snapshot') + + assert _format_playlist_sync_status(status, 'current-snapshot') == 'Last Sync: Jun 25, 12:00' diff --git a/web_server.py b/web_server.py index 4e330c92..63cab778 100644 --- a/web_server.py +++ b/web_server.py @@ -20765,6 +20765,56 @@ def _save_sync_status_file(sync_statuses): except Exception as e: logger.error(f"Error saving sync status: {e}") +def _sync_status_timestamp(status_info): + """Return comparable timestamp for a persisted sync-status record.""" + if not status_info or 'last_synced' not in status_info: + return None + try: + return datetime.fromisoformat(status_info['last_synced']) + except (TypeError, ValueError): + return None + +def _latest_sync_status(*status_infos): + """Pick the newest non-empty sync-status record.""" + candidates = [s for s in status_infos if s] + if not candidates: + return {} + return max(candidates, key=lambda s: _sync_status_timestamp(s) or datetime.min) + +def _mirrored_spotify_sync_status(playlist_id, sync_statuses, *, database=None, profile_id=None): + """Return auto-sync status for a Spotify playlist mirrored into SoulSync.""" + try: + db = database or get_database() + profile = profile_id if profile_id is not None else get_current_profile_id() + mirrored = db.get_mirrored_playlist_by_source('spotify', str(playlist_id), profile) + if not mirrored: + return {} + return sync_statuses.get(f"auto_mirror_{mirrored.get('id')}", {}) + except Exception as e: + logger.debug("Spotify mirrored sync-status lookup failed for %s: %s", playlist_id, e) + return {} + +def _resolve_spotify_playlist_sync_status(playlist_id, sync_statuses, *, database=None, profile_id=None): + """Resolve direct or mirrored sync status for a Spotify playlist card.""" + direct_status = sync_statuses.get(playlist_id, {}) + mirrored_status = _mirrored_spotify_sync_status( + playlist_id, + sync_statuses, + database=database, + profile_id=profile_id, + ) + return _latest_sync_status(direct_status, mirrored_status) + +def _format_playlist_sync_status(status_info, playlist_snapshot): + """Build user-facing sync-status text from persisted status + snapshot.""" + if 'last_synced' not in status_info: + return "Never Synced" + last_sync_time = datetime.fromisoformat(status_info['last_synced']).strftime('%b %d, %H:%M') + stored_snapshot = status_info.get('snapshot_id') + if stored_snapshot and playlist_snapshot and playlist_snapshot != stored_snapshot: + return f"Last Sync: {last_sync_time}" + return f"Synced: {last_sync_time}" + def _update_and_save_sync_status(playlist_id, playlist_name, playlist_owner, snapshot_id, **kwargs): """Updates the sync status for a given playlist and saves to file (same logic as GUI).""" try: @@ -20807,16 +20857,14 @@ def get_spotify_playlists(): # Add regular playlists first for p in playlists: - status_info = sync_statuses.get(p.id, {}) - sync_status = "Never Synced" + status_info = _resolve_spotify_playlist_sync_status(p.id, sync_statuses) # Handle snapshot_id safely - may not exist in core Playlist class playlist_snapshot = getattr(p, 'snapshot_id', '') + sync_status = _format_playlist_sync_status(status_info, playlist_snapshot) if 'last_synced' in status_info: stored_snapshot = status_info.get('snapshot_id') - last_sync_time = datetime.fromisoformat(status_info['last_synced']).strftime('%b %d, %H:%M') - if playlist_snapshot != stored_snapshot: - sync_status = f"Last Sync: {last_sync_time}" + if stored_snapshot and playlist_snapshot and playlist_snapshot != stored_snapshot: logger.info( "Playlist sync status: name=%s id=%s snapshot=%r stored_snapshot=%r result=Needs Sync display=%s", p.name, @@ -20826,7 +20874,6 @@ def get_spotify_playlists(): sync_status, ) else: - sync_status = f"Synced: {last_sync_time}" logger.info( "Playlist sync status: name=%s id=%s snapshot=%r stored_snapshot=%r result=Synced display=%s", p.name, diff --git a/webui/static/sync-spotify.js b/webui/static/sync-spotify.js index 6af72167..cc888f1f 100644 --- a/webui/static/sync-spotify.js +++ b/webui/static/sync-spotify.js @@ -1639,7 +1639,7 @@ function renderSpotifyPlaylists() { container.innerHTML = spotifyPlaylists.map(p => { let statusClass = 'status-never-synced'; if (p.sync_status.startsWith('Synced')) statusClass = 'status-synced'; - if (p.sync_status === 'Needs Sync') statusClass = 'status-needs-sync'; + if (p.sync_status === 'Needs Sync' || p.sync_status.startsWith('Last Sync')) statusClass = 'status-needs-sync'; // This HTML structure creates the interactive playlist cards return ` From 71aa3397bfcdd7f37c75234c2cb97d60c1683b8e Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Thu, 25 Jun 2026 10:15:00 -0700 Subject: [PATCH 80/96] Music automations page: hide video-owned automations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Video-side automations (owned_by='video') live in the shared automation-engine DB and were rendering on the music automations page across branches. Filter them out client-side — the /api/automations endpoint is shared with the video page + auto-sync board, so it can't filter server-side. Pure no-op for anyone without the video side (they have no such rows); auto_sync rows untouched. --- webui/static/stats-automations.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 922e6e0d..f08e2e23 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -2611,8 +2611,12 @@ async function loadAutomations() { if (!list || !empty) return; try { const res = await fetch('/api/automations'); - const automations = await res.json(); - if (automations.error) throw new Error(automations.error); + const payload = await res.json(); + if (payload.error) throw new Error(payload.error); + // Hide video-app automations on the MUSIC page: they live in the shared automation + // engine (music_library.db, owned_by='video') but belong to the separate video + // automations page. Pure no-op for anyone without the video side — they have no such rows. + const automations = (Array.isArray(payload) ? payload : []).filter(a => a.owned_by !== 'video'); if (!automations.length) { list.innerHTML = ''; empty.style.display = ''; if (statsBar) statsBar.innerHTML = ''; From 9c91ba29bff2939925eea8e77f111a25bf8c95c2 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Thu, 25 Jun 2026 10:15:20 -0700 Subject: [PATCH 81/96] Discover: listening-driven recommendations + mix (#913), Fresh Tape fix #913 was silently producing 0 recs: similar_artists.source_artist_id is a SOURCE id (Spotify/etc.), but the scan keyed id->name by internal artists.id (resolved nothing), and the consensus ranker was fed the name-collapsed get_top_similar_artists (consensus could never fire). Fixed + elevated: - id->name keyed by source-id columns; raw per-seed edges (real consensus); similarity_rank threaded into the score; recency-weighted seeds (recent plays boost lifetime favs) - new 'Based On Your Listening' artist row (/api/discover/listening-recommendations) with 'because you listen to X' explanations - new 'Your Listening Mix' track row: each rec's top tracks via a guarded, name-resolved Spotify/Deezer fetch (falls back to the discovery pool), stored as full render dicts so the row can't shrink on pool rotation - pure tested core: similarity_from_rank, build_recency_weighted_seeds, to_mix_track, names_match (+ rank-aware grouping) Fresh Tape (5-10 tracks): future-dated albums sorted to the top of get_discovery_recent_albums and ate the 50-album budget before the is_future_release skip ran. Add exclude_future_years + fetch a generous budget; downstream caps unchanged. Regression tested. Also drop the per-track block 'X' from the compact playlist rows (wrong spot). Plan/audit in DISCOVER_BEST_IN_CLASS_PLAN.md. --- DISCOVER_BEST_IN_CLASS_PLAN.md | 74 ++++++ core/discovery/listening_recommendations.py | 126 +++++++++- core/watchlist_scanner.py | 222 +++++++++++++++--- database/music_database.py | 29 ++- .../test_listening_recommendations.py | 127 ++++++++++ .../test_recent_albums_future_filter.py | 57 +++++ tests/test_watchlist_scanner_scan.py | 2 +- web_server.py | 90 +++++++ webui/index.html | 47 ++++ webui/static/discover.js | 105 ++++++++- 10 files changed, 826 insertions(+), 53 deletions(-) create mode 100644 DISCOVER_BEST_IN_CLASS_PLAN.md create mode 100644 tests/discovery/test_recent_albums_future_filter.py diff --git a/DISCOVER_BEST_IN_CLASS_PLAN.md b/DISCOVER_BEST_IN_CLASS_PLAN.md new file mode 100644 index 00000000..b406ad52 --- /dev/null +++ b/DISCOVER_BEST_IN_CLASS_PLAN.md @@ -0,0 +1,74 @@ +# discover page — best in class plan (#913 + full generator audit) + +morning notes. did the work overnight. tl;dr at top, details below, all of it `break nothing` + tested. + +## what i shipped tonight (done, tested, safe) + +### 1. listening recommendations (#913) — went from BROKEN to best-in-class + +the feature was silently producing **zero** recs on real data. dug in and found three stacked bugs in the generation: + +- **wrong id key (the killer).** `similar_artists.source_artist_id` is a *source* id (spotify/itunes/deezer), but the scanner built its id→name map from `artists.id` (the internal row id). so every edge resolved to nothing → 0 recs. proved it on your live db: internal-id join = 0 rows, spotify-id join = 71,636 rows. +- **consensus could never fire.** it fed the ranker `get_top_similar_artists`, which does `GROUP BY similar_artist_name` + `MAX(source_artist_id)` — collapsing every similar artist down to a *single* seed. the whole point of the ranker is "artist X is similar to 3 of your seeds = strong signal," and that signal was being flattened away before it ever reached the ranker. +- **similarity strength thrown away.** each edge stores a 1-10 closeness rank; it was ignored (everything weighted equally). + +the fix (all in the pure, tested core + thin scan wiring): +- build id→name from the **source-id columns**, query the **raw per-seed edges** (consensus preserved), and thread **similarity_rank** into the score so a seed's closest matches count for more. +- **recency-weighted seeds**: `weight = lifetime_plays + 1.5 × recent_30d_plays`. picks now track what you're into *now*, not just all-time totals. + +result on your actual library (simulated through the real code path): **40 recommendations, 13 with multi-seed consensus, all 40 with cached art.** top picks: Arcangel (Bad Bunny + Ozuna + J Balvin), Melanie Martinez (Ariana + Billie), Maluma, De La Ghetto — all coherent, all explainable. + +### 2. its own row on the discover page + +new row **"Based On Your Listening"** — play-weighted, consensus-ranked artist cards with a **"Because you listen to X, Y"** line. sits right above the library-driven "Recommended For You" row. purely additive: new endpoint `/api/discover/listening-recommendations`, new loader, hides itself when empty. + +**you need to run one watchlist scan** for the row to populate (the data regenerates during the scan — i did NOT touch your live db). before that scan the row just stays hidden; after it, it fills in. + +> note: this is deliberately different from the existing "Recommended For You" row. that one is driven by your *whole library / watchlist*. this one is driven by your *actual listening intensity* — the ~30 artists you really play, not the thousands you happen to own. + +### 3. Fresh Tape "only 5-10 tracks" — fixed + +root cause: `get_discovery_recent_albums` orders `release_date DESC`, so announced-but-unreleased albums sort to the *top* and ate the 50-album budget. the scanner skipped them *after* the budget was already spent → only a handful of released albums left → 5-10 tracks. fixed by fetching a generous budget (300) **and** excluding next-year albums at the query, so released albums fill the budget. the precise same-year `is_future_release` skip stays as a second guard. downstream caps (6/artist, top 75, take 50) unchanged. + +**tests:** 25 pure-core cases (consensus/similarity/recency) + 2 Fresh Tape regression tests, all green. full discovery suite (255) green. nothing else touched. + +--- + +## best-in-class roadmap for listening recs (next phases — your call) + +these are the levers to take it further. ordered by value-to-risk. none are required; tonight's work stands on its own. + +| phase | what | value | risk | notes | +|---|---|---|---|---| +| **3** | **playable track row** ✅ DONE | high | low-med | shipped: "🎧 Your Listening Mix" row — a track playlist (play/queue/download/sync) right under the artist row. stored as full render-ready dicts (not pool-hydrated, so it can't shrink on pool rotation like Fresh Tape does). | +| **4** | **direct top-tracks fetch** ✅ DONE | high | med | shipped: scan fetches each recommended artist's top tracks (Spotify/Deezer), resolving the artist id by name-search when the similar-artist row lacks one — guarded by a strict name-match so it never pulls the wrong artist. bounded (top 20 recs), per-call guarded, fail-soft to the pool. iTunes has no top-tracks API → pool-only there. needs a live scan to populate. | +| **5** | **genre-affinity boost** | med | low | we already compute your genre breakdown. boost recs whose genres match your top genres → tighter taste alignment. pure scoring add. | +| **6** | **adventurousness dial** | med | low | the ranker already supports `min_seed_count` (consensus floor). expose it as a "Safe ↔ Adventurous" slider on the row. | +| **7** | **diversity pass** | low-med | low | avoid 40 recs all orbiting your single heaviest seed — cap picks-per-seed so the row spans your taste. | + +the core is built to absorb all of these without re-plumbing — `similarity_from_rank`, `build_recency_weighted_seeds`, and the scoring formula are all pure + tested. + +--- + +## full discover-page generator audit (every soulsync-built row, excluding last.fm + listenbrainz) + +how each one is generated today, and whether it can be elevated. "clear win" = safe + additive. "product call" = needs your decision (changes the row's character). + +### curated (built during the scan, then hydrated) +- **Fresh Tape / Release Radar** — new releases from watchlist+similar artists. **FIXED tonight** (see above). one more *clear win* available: hydration silently drops any curated id no longer in the discovery pool — could fall back to the stored `track_data_json` blob so the row can't shrink at read time. +- **The Archives / Discovery Weekly** — strong already. nice 3-tier popularity split + serendipity scoring (boost never-played artists, penalize overplayed). same hydration-drop caveat as Fresh Tape; same cheap fallback fix. +- **Seasonal Mix** — cleanest of the bunch. hydrates from a dedicated `seasonal_tracks` table (carries its own data), so it doesn't suffer the pool-drop problem. no bug. + +### discovery-pool generators (live queries) +- **Popular Picks** — ranks by popularity DESC. solid. only nit: on iTunes (no popularity scale) it silently degrades to random — indistinguishable from Shuffle there. UI-label thing at most. +- **Hidden Gems** — *clear win*. currently `ORDER BY RANDOM()` over low-popularity tracks — so it's "random obscure," not "*best* obscure." a light ranking (popularity just under the threshold, or genre-affinity to you) would make it feel curated instead of arbitrary. (a deeper *product call*: add personalization like Archives has — bigger lift, changes its "pure underground" character.) +- **Genre Playlists** — good. pushes the genre match into SQL. `RANDOM()` ordering is fine for a browse; a popularity/affinity tiebreak (*clear win*) would make thin genres feel less arbitrary. +- **Discovery Shuffle** — random by design, correct. only possible add: exclude tracks already shown in other rows this refresh (needs a cross-section seen-set — medium plumbing). +- **Time Machine (by decade)** — *clear win, low risk*: decades are hardcoded, so a modern-only library shows 7 decade tabs, 5 empty. filter the tabs to decades that actually have pool data. +- **Daily Mix** — the weakest row. the "50% your library" half permanently returns nothing (library tracks have no source ids to play), so each Daily Mix is really just a relabeled Genre Playlist. real fix = backfill source ids into library rows (*schema-level, higher risk*) — worth a dedicated pass, not a quick tweak. also silently falls back to "top artists as pseudo-genres" when genre data is missing → "Daily Mix 1" becomes mislabeled artist-radio. gate/label that (*clear win*). + +### cross-cutting +- **hydration fragility** (Fresh Tape + Archives): both depend on curated ids still living in the pool at read time; misses are dropped silently. Seasonal already solved this with a dedicated table. giving the two spotify-style rows the same data-blob fallback is the single most robust cross-cutting fix. low risk, clear win. +- **RANDOM-ordering pattern** (Hidden Gems, Shuffle, Genre, Decade): intentional for variety, but leaves quality signal on the table for the non-shuffle rows. adding a light ranking pass to Hidden Gems + Genre is the biggest "best-in-class" lever after tonight's work. + +want me to take any of these? the Hidden Gems ranking + Time Machine empty-decade filter + the Fresh Tape/Archives hydration fallback are all safe, additive, same-shape-as-tonight wins i can knock out next. diff --git a/core/discovery/listening_recommendations.py b/core/discovery/listening_recommendations.py index 9af7b417..3bc544e6 100644 --- a/core/discovery/listening_recommendations.py +++ b/core/discovery/listening_recommendations.py @@ -46,6 +46,67 @@ def _get(row: object, attr: str): return getattr(row, attr, None) +def names_match(a: object, b: object) -> bool: + """Strict artist-name equality after stripping case + non-alphanumerics. + + Used to verify a name-search result before fetching that artist's top tracks, so the + "Listening Mix" can never pull the WRONG artist's songs (e.g. a same-name act). Exact + alphanumeric match: "Tyler, The Creator" == "Tyler The Creator", but "Drake" != "Drake Bell". + Pure. + """ + def _alnum(x: object) -> str: + return "".join(ch for ch in str(x or "").lower() if ch.isalnum()) + na, nb = _alnum(a), _alnum(b) + return bool(na) and na == nb + + +def similarity_from_rank(rank: object, max_rank: int = 10) -> float: + """Turn a stored ``similarity_rank`` (1 = most similar … 10 = least) into a 0–1 weight. + + SoulSync stores each ``(seed → similar)`` edge with a 1–10 rank (``1`` is the closest + match). The ranker multiplies this into the score so a seed's *closest* matches count + for more than its long-tail ones. Linear decay over the documented range: rank 1 → 1.0, + rank 5 → 0.6, rank 10 → 0.1, with a 0.1 floor so a far match still contributes. A + missing/garbage rank falls back to 1.0 (treat as "no rank info, full weight"). Pure. + """ + try: + r = int(rank) + except (TypeError, ValueError): + return 1.0 + floor = round(1.0 / max_rank, 4) + if r <= 1: + return 1.0 + if r >= max_rank: + return floor + return round((max_rank - r + 1) / max_rank, 4) + + +def build_recency_weighted_seeds( + top_artists: Sequence[dict], + recent_play_counts: Optional[Dict[str, float]] = None, + *, + recency_factor: float = 1.5, +) -> List[dict]: + """Blend lifetime + recent play counts into seed weights — "what you're into NOW". + + ``weight = lifetime_plays + recency_factor × recent_plays``. An artist you've played a + lot *recently* outranks one you played a lot years ago, so the recommendations track + your current taste instead of your all-time history. ``recency_factor`` is the dial + (0 = pure lifetime). Returns ``[{'name', 'weight'}]`` for :func:`rank_recommended_artists`. + Pure — the caller supplies both play-count maps from the listening history. + """ + recent = {_norm(k): _positive_float(v, 0.0) for k, v in (recent_play_counts or {}).items()} + out: List[dict] = [] + for a in top_artists or (): + name = str(a.get("name") or "").strip() + if not name: + continue + lifetime = _positive_float(a.get("play_count", a.get("weight", 1.0))) + boost = recency_factor * recent.get(_norm(name), 0.0) + out.append({"name": name, "weight": lifetime + boost}) + return out + + def group_similars_by_seed( seeds: Sequence[dict], similar_rows: Sequence, @@ -53,14 +114,21 @@ def group_similars_by_seed( *, source_id_attr: str = "source_artist_id", similar_name_attr: str = "similar_artist_name", + rank_attr: Optional[str] = None, ) -> Dict[str, List[dict]]: - """Reshape flat ``similar_artists`` rows into ``{seed_name_lower: [{'name': similar}]}``. + """Reshape flat ``similar_artists`` rows into ``{seed_name_lower: [{'name', 'score'?}]}``. The stored rows key the similar artist by the SEED's source id (``source_artist_id``), not its name, so :func:`rank_recommended_artists` can't consume them directly. This resolves each row's source id to a name via ``id_to_name`` (``{source_artist_id: artist_name}`` for the library, built by the caller) and keeps only rows that resolve to one of the ``seeds``. Rows may be dataclass objects or dicts. Pure — no I/O. + + ``id_to_name`` MUST be keyed by whatever id the edges actually store — for SoulSync that + is the artist's SOURCE id (Spotify/iTunes/Deezer/MusicBrainz), NOT the internal row id. + When ``rank_attr`` is given, each row's rank is converted via :func:`similarity_from_rank` + and carried as ``score`` so closer matches weigh more; without it every similar comes out + score-less (the ranker then treats similarity as 1.0 — original behavior). """ seed_names = {_norm(s.get("name")) for s in seeds} seed_names.discard("") @@ -72,8 +140,12 @@ def group_similars_by_seed( if not seed_name or seed_name not in seed_names: continue sim_name = str(_get(row, similar_name_attr) or "").strip() - if sim_name: - out.setdefault(seed_name, []).append({"name": sim_name}) + if not sim_name: + continue + entry = {"name": sim_name} + if rank_attr is not None: + entry["score"] = similarity_from_rank(_get(row, rank_attr)) + out.setdefault(seed_name, []).append(entry) return out @@ -197,8 +269,56 @@ def aggregate_candidate_tracks( return out[:limit] +def to_mix_track(track: object, source: str) -> Optional[dict]: + """Shape one source "top tracks" API dict into the flat dict the Discover compact + playlist row renders + syncs (the "Listening Mix" #913 playlist). + + Spotify's ``artist_top_tracks`` and Deezer's ``get_artist_top_tracks`` both return the + same Spotify-shape object (``id, name, artists[], album{name,images[]}, duration_ms``). + This flattens that into the renderer's field names (``track_name/artist_name/album_name/ + album_cover_url/duration_ms``), keeps the original under ``track_data_json`` for sync, and + sets the source-specific id field. Returns None for anything without a usable id/title so + the caller can filter. A ``name`` key is kept so :func:`aggregate_candidate_tracks` can + dedup by title. Pure — no I/O. + """ + if not isinstance(track, dict): + return None + tid = track.get("id") + name = str(track.get("name") or "").strip() + if not tid or not name: + return None + artists = track.get("artists") or [] + artist_name = "" + if artists and isinstance(artists[0], dict): + artist_name = str(artists[0].get("name") or "").strip() + album = track.get("album") if isinstance(track.get("album"), dict) else {} + album_name = str(album.get("name") or "").strip() + images = album.get("images") or [] + cover = images[0].get("url") if images and isinstance(images[0], dict) else None + out = { + "track_id": str(tid), + "name": name, # for aggregate_candidate_tracks dedup + "track_name": name, # for the renderer + "artist_name": artist_name, + "album_name": album_name, + "album_cover_url": cover, + "duration_ms": track.get("duration_ms") or 0, + "track_data_json": track, # full payload for sync/download + "source": source, + } + id_field = {"spotify": "spotify_track_id", "deezer": "deezer_track_id", + "itunes": "itunes_track_id"}.get(source) + if id_field: + out[id_field] = str(tid) + return out + + __all__ = [ "RecommendedArtist", + "names_match", + "similarity_from_rank", + "build_recency_weighted_seeds", + "to_mix_track", "group_similars_by_seed", "rank_recommended_artists", "aggregate_candidate_tracks", diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index 33a74cf4..488ffca4 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -3633,8 +3633,13 @@ class WatchlistScanner: for source in sources_to_process: logger.info(f"Curating Release Radar for {source}...") - # 1. Curate Release Radar - 50 tracks from recent albums - recent_albums = self.database.get_discovery_recent_albums(limit=50, source=source, profile_id=profile_id) + # 1. Curate Release Radar - 50 tracks from recent albums. + # Fetch a GENEROUS album budget (not 50) and exclude next-year announcements at the + # query: future-dated albums sort to the top of release_date DESC and used to eat the + # budget before the in-loop is_future_release skip ran, starving Fresh Tape to a few + # tracks. The downstream caps (6/artist, top 75, take 50) still bound the output. + recent_albums = self.database.get_discovery_recent_albums( + limit=300, source=source, profile_id=profile_id, exclude_future_years=True) release_radar_tracks = [] if not recent_albums: @@ -4004,45 +4009,129 @@ class WatchlistScanner: The ranking lives in core.discovery.listening_recommendations (pure + tested); this only gathers inputs — all already in the DB, NO new network — and stores the result under NEW metadata/curated keys. Fully self-contained and guarded: any failure logs and returns, so - it can never disturb the scan. Phase-1 candidate tracks come from the discovery pool (like - BYLT); a later phase swaps in a direct top-tracks fetch for richer coverage. + it can never disturb the scan. + + "Best in class" generation (the elevation over the first cut): + • Seeds are recency-weighted — recent plays boost lifetime favourites so the picks + track what you're into NOW, not just all-time totals. + • The ranker is fed the RAW per-seed edges (one row per seed→similar), so an artist + similar to several of your seeds accumulates real CONSENSUS — the old code fed the + name-collapsed ``get_top_similar_artists`` query, which flattened every similar to a + single seed (consensus could never fire). The raw edges also carry ``similarity_rank``, + so a seed's CLOSEST matches outweigh its long-tail ones. + • ``source_artist_id`` is a SOURCE id (Spotify/iTunes/Deezer/MusicBrainz), so the id→name + map is built from the artists' source-id columns, NOT the internal row id (the first + cut keyed it by ``artists.id`` and resolved nothing — the feature produced 0 recs). + Phase-1 candidate tracks still come from the discovery pool (like BYLT); a later phase + swaps in a direct top-tracks fetch for richer coverage. """ try: import json as _json from core.discovery.listening_recommendations import ( aggregate_candidate_tracks, + build_recency_weighted_seeds, group_similars_by_seed, + names_match, rank_recommended_artists, + to_mix_track, ) - seeds = [{'name': s['name'], 'weight': s.get('play_count', 1)} - for s in (self.database.get_top_artists('all', 30) or []) if s.get('name')] - if not seeds: + # Recency-weighted seeds: lifetime top artists, boosted by recent (30d) plays. + lifetime = [s for s in (self.database.get_top_artists('all', 30) or []) if s.get('name')] + if not lifetime: return + recent_rows = self.database.get_top_artists('30d', 50) or [] + recent_counts = {r['name'].lower(): r.get('play_count', 0) + for r in recent_rows if r.get('name')} + seeds = build_recency_weighted_seeds(lifetime, recent_counts) + seed_names = {s['name'].lower() for s in seeds} - # id -> name + owned-artist set for the WHOLE library (similar_artists rows key the - # similar artist by the seed artist's id). - id_to_name, owned = {}, set() + # Owned-artist set (for exclusion) + the seeds' SOURCE ids (similar_artists.source_artist_id + # is a Spotify/iTunes/Deezer/MusicBrainz id, never the internal artists.id). We only need + # id→name for the SEED ids, since the edge query below is already scoped to them. + owned, seed_source_ids, seed_id_to_name = set(), [], {} with self.database._get_connection() as conn: cur = conn.cursor() - cur.execute("SELECT id, name FROM artists WHERE name IS NOT NULL AND name != ''") + cur.execute("SELECT name, spotify_artist_id, itunes_artist_id, deezer_id, " + "musicbrainz_id FROM artists WHERE name IS NOT NULL AND name != ''") for row in cur.fetchall(): - id_to_name[str(row[0])] = row[1] - owned.add((row[1] or '').lower()) + nm = row[0] + lname = (nm or '').lower() + owned.add(lname) + if lname in seed_names: + for sid in (row[1], row[2], row[3], row[4]): + if sid: + seed_source_ids.append(str(sid)) + seed_id_to_name[str(sid)] = nm + + # RAW per-seed edges (preserve consensus + similarity_rank). Scoped to the seeds. + edges, edge_cols = [], ('source_artist_id', 'similar_artist_name', 'similarity_rank', + 'spotify_id', 'itunes_id', 'deezer_id', 'image_url', 'genres') + if seed_source_ids: + placeholders = ",".join("?" * len(seed_source_ids)) + cur.execute( + f"SELECT source_artist_id, similar_artist_name, similarity_rank, " + f"similar_artist_spotify_id, similar_artist_itunes_id, " + f"similar_artist_deezer_id, image_url, genres FROM similar_artists " + f"WHERE profile_id = ? AND source_artist_id IN ({placeholders})", + [profile_id, *seed_source_ids]) + edges = [dict(zip(edge_cols, r, strict=False)) for r in cur.fetchall()] + + # Per-name enrichment (image/ids/genres) so the Discover row can render rich cards. + artist_meta_by_name = {} + for e in edges: + key = (e['similar_artist_name'] or '').lower() + if not key: + continue + m = artist_meta_by_name.setdefault(key, {}) + for src_k, dst_k in (('spotify_id', 'spotify_artist_id'), + ('itunes_id', 'itunes_artist_id'), + ('deezer_id', 'deezer_artist_id')): + if e.get(src_k) and not m.get(dst_k): + m[dst_k] = e[src_k] + if e.get('image_url') and not m.get('image_url'): + m['image_url'] = e['image_url'] + if e.get('genres') and not m.get('genres'): + m['genres'] = e['genres'] + + similars_by_seed = group_similars_by_seed( + seeds, edges, seed_id_to_name, rank_attr='similarity_rank') + + # Fallback: if the raw edges resolved nothing (e.g. source ids not yet populated), + # degrade to the aggregated query so the feature still works rather than going dark. + if not similars_by_seed: + agg = self.database.get_top_similar_artists(limit=1000, profile_id=profile_id) + similars_by_seed = group_similars_by_seed( + seeds, agg, seed_id_to_name, rank_attr='similarity_rank') - similar_rows = self.database.get_top_similar_artists(limit=1000, profile_id=profile_id) - similars_by_seed = group_similars_by_seed(seeds, similar_rows, id_to_name) recs = rank_recommended_artists(seeds, similars_by_seed, owned, limit=40) if not recs: logger.info("[Listening Recs] no recommendations yet (no similar-artist coverage)") return - self.database.set_metadata('listening_recs_artists', _json.dumps([ - {'name': r.name, 'seed_count': r.seed_count, 'seeds': r.seeds[:5], 'score': r.score} - for r in recs - ])) + def _enrich(r): + m = artist_meta_by_name.get(r.name.lower(), {}) + genres = m.get('genres') + if isinstance(genres, str): + try: + genres = _json.loads(genres) + except Exception: + genres = None + return {'name': r.name, 'seed_count': r.seed_count, 'seeds': r.seeds[:5], + 'score': r.score, 'spotify_artist_id': m.get('spotify_artist_id'), + 'itunes_artist_id': m.get('itunes_artist_id'), + 'deezer_artist_id': m.get('deezer_artist_id'), + 'image_url': m.get('image_url'), + 'genres': (genres[:3] if isinstance(genres, list) else None)} - # Candidate tracks from the discovery pool grouped by artist (phase 1: no new network). + self.database.set_metadata('listening_recs_artists', + _json.dumps([_enrich(r) for r in recs])) + + # Candidate tracks for the "Listening Mix" playlist row: each recommended artist's + # top tracks. Prefer a DIRECT top-tracks fetch (Spotify/Deezer — richest + most + # accurate), fall back to the discovery pool (covers iTunes + any artist the fetch + # missed). Stored as full render-ready dicts so the row needs NO pool re-hydration — + # robust against pool rotation (the bug that shrinks Fresh Tape/Archives at read time). pool, active_source = [], None for src in (sources_to_process or []): pool = self.database.get_discovery_pool_tracks( @@ -4050,24 +4139,85 @@ class WatchlistScanner: if pool: active_source = src break + if not active_source: + active_source = (sources_to_process or ['spotify'])[0] - track_ids = [] - if pool: - by_artist = {} - for t in pool: - an = (getattr(t, 'artist_name', '') or '').lower() - tid = (getattr(t, 'spotify_track_id', None) if active_source == 'spotify' - else getattr(t, 'itunes_track_id', None) if active_source == 'itunes' - else getattr(t, 'deezer_track_id', None)) - if an and tid: - by_artist.setdefault(an, []).append({'name': getattr(t, 'track_name', ''), 'id': tid}) - candidates = aggregate_candidate_tracks(recs, by_artist, per_artist=3, limit=50) - track_ids = [c['id'] for c in candidates if c.get('id')] - if track_ids: - self.database.save_curated_playlist('listening_recs_tracks', track_ids, profile_id=profile_id) + # Pool baseline grouped by artist (full render dicts), no network. + pool_by_artist = {} + for t in (pool or []): + an = (getattr(t, 'artist_name', '') or '').lower() + tid = (getattr(t, 'spotify_track_id', None) if active_source == 'spotify' + else getattr(t, 'itunes_track_id', None) if active_source == 'itunes' + else getattr(t, 'deezer_track_id', None)) + name = getattr(t, 'track_name', '') or '' + if not an or not tid or not name: + continue + tdj = getattr(t, 'track_data_json', None) + if isinstance(tdj, str): + try: + tdj = _json.loads(tdj) + except Exception: + tdj = None + pool_by_artist.setdefault(an, []).append({ + 'track_id': str(tid), 'name': name, 'track_name': name, + 'artist_name': getattr(t, 'artist_name', '') or '', + 'album_name': getattr(t, 'album_name', '') or '', + 'album_cover_url': getattr(t, 'album_cover_url', None), + 'duration_ms': getattr(t, 'duration_ms', 0) or 0, + 'track_data_json': tdj, 'source': active_source, + f'{active_source}_track_id': str(tid)}) - logger.info("[Listening Recs] %d recommended artists, %d candidate tracks", - len(recs), len(track_ids)) + # Direct top-tracks enrichment — guarded, bounded (top 20 recs), fail-soft per artist. + fetched_by_artist = {} + try: + if active_source in ('spotify', 'deezer'): + client = get_client_for_source(active_source) + if client and hasattr(client, 'get_artist_top_tracks'): + id_key = 'spotify_artist_id' if active_source == 'spotify' else 'deezer_artist_id' + can_search = hasattr(client, 'search_artists') + for r in recs[:20]: + aid = artist_meta_by_name.get(r.name.lower(), {}).get(id_key) + # Most similar-artist rows store a name but no source id, so resolve + # it by name-search — guarded by names_match so we never fetch the + # WRONG artist's tracks (a same-name act). + if not aid and can_search: + try: + found = client.search_artists(r.name, limit=1) or [] + except Exception as _s_err: + logger.debug("[Listening Recs] artist search failed for %s: %s", + r.name, _s_err) + found = [] + if found and names_match(r.name, getattr(found[0], 'name', '')): + aid = getattr(found[0], 'id', None) + if not aid: + continue + try: + raw = client.get_artist_top_tracks(str(aid), limit=8) or [] + except Exception as _tt_err: + logger.debug("[Listening Recs] top-tracks fetch failed for %s: %s", + r.name, _tt_err) + continue + shaped = [s for s in (to_mix_track(x, active_source) for x in raw) if s][:5] + if shaped: + fetched_by_artist[r.name.lower()] = shaped + except Exception as _enr_err: + logger.debug("[Listening Recs] top-tracks enrichment skipped: %s", _enr_err) + + # Merge: prefer fetched top tracks, fall back to the pool per artist. + top_tracks_by_artist = {} + for r in recs: + merged = fetched_by_artist.get(r.name.lower()) or pool_by_artist.get(r.name.lower()) + if merged: + top_tracks_by_artist[r.name.lower()] = merged + + mix = aggregate_candidate_tracks(recs, top_tracks_by_artist, per_artist=3, limit=50) + track_ids = [m.get('track_id') for m in mix if m.get('track_id')] + if mix: + self.database.set_metadata('listening_recs_tracks_full', _json.dumps(mix)) + self.database.save_curated_playlist('listening_recs_tracks', track_ids, profile_id=profile_id) + + logger.info("[Listening Recs] %d recommended artists, %d mix tracks (%d artists via top-tracks fetch)", + len(recs), len(mix), len(fetched_by_artist)) except Exception as e: logger.debug("[Listening Recs] generation skipped: %s", e) diff --git a/database/music_database.py b/database/music_database.py index c173015b..97925c32 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -10884,23 +10884,40 @@ class MusicDatabase: logger.error(f"Error caching discovery recent album: {e}") return False - def get_discovery_recent_albums(self, limit: int = 10, source: Optional[str] = None, profile_id: int = 1) -> List[Dict[str, Any]]: - """Get cached recent albums for discover page, optionally filtered by source""" + def get_discovery_recent_albums(self, limit: int = 10, source: Optional[str] = None, profile_id: int = 1, + exclude_future_years: bool = False) -> List[Dict[str, Any]]: + """Get cached recent albums for discover page, optionally filtered by source. + + exclude_future_years: drop announced-but-unreleased albums dated to a LATER YEAR. + Because rows are ordered ``release_date DESC``, future-dated albums otherwise sort to + the very top and consume the ``limit`` budget — which is exactly why Fresh Tape / Release + Radar starved down to a handful of tracks. Year-level so it's precision-safe across + 'YYYY' / 'YYYY-MM' / 'YYYY-MM-DD'; same-year future months are left for the caller's precise + ``is_future_release`` check. NULL/blank dates are kept (treated as released). + """ try: with self._get_connection() as conn: cursor = conn.cursor() + future_clause = "" + if exclude_future_years: + future_clause = ( + " AND (release_date IS NULL OR release_date = '' " + "OR CAST(substr(release_date, 1, 4) AS INTEGER) " + "<= CAST(strftime('%Y','now') AS INTEGER))" + ) + if source: - cursor.execute(""" + cursor.execute(f""" SELECT * FROM discovery_recent_albums - WHERE source = ? AND profile_id = ? + WHERE source = ? AND profile_id = ?{future_clause} ORDER BY release_date DESC LIMIT ? """, (source, profile_id, limit)) else: - cursor.execute(""" + cursor.execute(f""" SELECT * FROM discovery_recent_albums - WHERE profile_id = ? + WHERE profile_id = ?{future_clause} ORDER BY release_date DESC LIMIT ? """, (profile_id, limit)) diff --git a/tests/discovery/test_listening_recommendations.py b/tests/discovery/test_listening_recommendations.py index a4610c80..87ec52c1 100644 --- a/tests/discovery/test_listening_recommendations.py +++ b/tests/discovery/test_listening_recommendations.py @@ -4,14 +4,67 @@ from __future__ import annotations from core.discovery.listening_recommendations import ( aggregate_candidate_tracks, + build_recency_weighted_seeds, + names_match, rank_recommended_artists, + similarity_from_rank, + to_mix_track, ) +# ── names_match (guards the top-tracks fetch against wrong-artist results) ───── +def test_names_match_ignores_case_and_punctuation(): + assert names_match("Tyler, The Creator", "Tyler The Creator") + assert names_match("BEYONCÉ", "beyoncé") + assert names_match("AC/DC", "ac dc") + + +def test_names_match_rejects_near_misses_and_empty(): + assert not names_match("Drake", "Drake Bell") + assert not names_match("", "anything") + assert not names_match("X", None) + + def _seed(name, weight=1.0): return {"name": name, "weight": weight} +# ── similarity_from_rank (1=closest .. 10=farthest -> 1.0 .. 0.1) ───────────── +def test_similarity_from_rank_decays_over_documented_range(): + assert similarity_from_rank(1) == 1.0 + assert similarity_from_rank(5) == 0.6 + assert similarity_from_rank(10) == 0.1 + + +def test_similarity_from_rank_clamps_and_defaults(): + assert similarity_from_rank(0) == 1.0 # <=1 -> full weight + assert similarity_from_rank(50) == 0.1 # beyond range -> floor + assert similarity_from_rank(None) == 1.0 # missing -> full weight (no rank info) + assert similarity_from_rank("nan") == 1.0 + + +# ── build_recency_weighted_seeds (lifetime + factor*recent) ─────────────────── +def test_recency_boost_reorders_toward_current_taste(): + # Old-fav has more lifetime plays, but New-fav dominates recently. + lifetime = [{"name": "OldFav", "play_count": 100}, {"name": "NewFav", "play_count": 40}] + recent = {"newfav": 60} + seeds = build_recency_weighted_seeds(lifetime, recent, recency_factor=1.5) + by = {s["name"]: s["weight"] for s in seeds} + assert by["OldFav"] == 100.0 # no recent plays -> unchanged + assert by["NewFav"] == 40 + 1.5 * 60 # 130 -> now outranks OldFav + + +def test_recency_factor_zero_is_pure_lifetime(): + seeds = build_recency_weighted_seeds( + [{"name": "A", "play_count": 7}], {"a": 99}, recency_factor=0) + assert seeds == [{"name": "A", "weight": 7.0}] + + +def test_recency_seeds_skip_blank_names_and_tolerate_missing_recent(): + seeds = build_recency_weighted_seeds([{"name": ""}, {"name": "A", "play_count": 3}]) + assert seeds == [{"name": "A", "weight": 3.0}] + + # ── rank_recommended_artists ───────────────────────────────────────────────── def test_consensus_outranks_single_endorsement(): # 'Common' is similar to BOTH seeds; 'Solo' to one. Equal weights/scores. @@ -117,6 +170,46 @@ def test_aggregate_skips_artist_with_no_tracks(): assert [t["name"] for t in out] == ["only"] # sim-b had no tracks -> skipped +# ── to_mix_track (source top-track dict -> Discover compact-row dict) ───────── +def _sp_track(tid="t1", name="Song", artist="Artist", album="Album", cover="http://cdn/c.jpg"): + return {"id": tid, "name": name, "artists": [{"name": artist}], + "album": {"name": album, "images": ([{"url": cover}] if cover else [])}, + "duration_ms": 210000, "popularity": 55} + + +def test_to_mix_track_shapes_render_fields(): + out = to_mix_track(_sp_track(), "spotify") + assert out["track_name"] == "Song" and out["artist_name"] == "Artist" + assert out["album_name"] == "Album" and out["album_cover_url"] == "http://cdn/c.jpg" + assert out["duration_ms"] == 210000 + assert out["spotify_track_id"] == "t1" and out["track_id"] == "t1" + assert out["track_data_json"]["id"] == "t1" # full payload kept for sync + assert out["name"] == "Song" # kept for aggregate dedup + + +def test_to_mix_track_source_id_field_per_source(): + assert to_mix_track(_sp_track(), "deezer")["deezer_track_id"] == "t1" + assert to_mix_track(_sp_track(), "itunes")["itunes_track_id"] == "t1" + + +def test_to_mix_track_rejects_unusable_and_tolerates_missing_album(): + assert to_mix_track({"id": "x"}, "spotify") is None # no title + assert to_mix_track({"name": "y"}, "spotify") is None # no id + assert to_mix_track("garbage", "spotify") is None + bare = to_mix_track({"id": "z", "name": "Z"}, "spotify") # no artists/album + assert bare["artist_name"] == "" and bare["album_cover_url"] is None + + +def test_to_mix_track_feeds_aggregate_end_to_end(): + # The real pipeline: shape source tracks, then aggregate by recommended artist. + recs = _recs("A") # recommends 'sim-A' + shaped = [to_mix_track(_sp_track(tid="1", name="One", artist="sim-A"), "spotify"), + to_mix_track(_sp_track(tid="2", name="Two", artist="sim-A"), "spotify")] + out = aggregate_candidate_tracks(recs, {"sim-a": shaped}, per_artist=5, limit=10) + assert [t["track_name"] for t in out] == ["One", "Two"] + assert out[0]["spotify_track_id"] == "1" + + # ── group_similars_by_seed (id->name join) ─────────────────────────────────── from dataclasses import dataclass as _dc # noqa: E402 @@ -167,3 +260,37 @@ def test_group_then_rank_end_to_end(): ranked = rank_recommended_artists(seeds, grouped, owned_artist_names={"solo"}) assert ranked[0].name == "Common" and ranked[0].seed_count == 2 assert all(r.name != "Solo" for r in ranked) # owned excluded + + +# ── rank-aware grouping (similarity_rank -> score) ──────────────────────────── +@_dc +class _RankRow: + source_artist_id: str + similar_artist_name: str + similarity_rank: int + + +def test_group_with_rank_attr_carries_similarity_score(): + seeds = [_seed("A")] + rows = [_RankRow("ia", "Close", 1), _RankRow("ia", "Far", 10)] + out = group_similars_by_seed(seeds, rows, {"ia": "A"}, rank_attr="similarity_rank") + by = {e["name"]: e["score"] for e in out["a"]} + assert by == {"Close": 1.0, "Far": 0.1} + + +def test_group_without_rank_attr_is_scoreless_backcompat(): + seeds = [_seed("A")] + rows = [_RankRow("ia", "X", 3)] + out = group_similars_by_seed(seeds, rows, {"ia": "A"}) + assert out["a"] == [{"name": "X"}] # no score key -> original behavior + + +def test_rank_threading_changes_winner_within_a_seed(): + # The production fix: a CLOSER match (rank 1) on a heavy seed beats a far match (rank 9), + # even though both come from the same seed. Without rank threading they'd tie. + seeds = [_seed("Fav", weight=10)] + rows = [_RankRow("if", "Close", 1), _RankRow("if", "Far", 9)] + grouped = group_similars_by_seed(seeds, rows, {"if": "Fav"}, rank_attr="similarity_rank") + ranked = rank_recommended_artists(seeds, grouped) + assert [r.name for r in ranked] == ["Close", "Far"] + assert ranked[0].score > ranked[1].score diff --git a/tests/discovery/test_recent_albums_future_filter.py b/tests/discovery/test_recent_albums_future_filter.py new file mode 100644 index 00000000..30b382c4 --- /dev/null +++ b/tests/discovery/test_recent_albums_future_filter.py @@ -0,0 +1,57 @@ +"""Fresh Tape / Release Radar candidate fetch must not be starved by future albums. + +Regression: get_discovery_recent_albums orders release_date DESC, so announced-but- +unreleased albums dated to a LATER YEAR sort to the very top and consumed the album +budget before the scanner's in-loop is_future_release skip ran — leaving only a handful +of released albums to draw tracks from (the reported "Fresh Tape only has 5-10 tracks"). +exclude_future_years drops next-year albums at the query so released ones fill the budget. +""" + +from __future__ import annotations + +from datetime import datetime + +from database.music_database import MusicDatabase + + +def _insert(db, **kw): + with db._get_connection() as conn: + conn.execute( + """INSERT INTO discovery_recent_albums + (album_spotify_id, album_name, artist_name, artist_spotify_id, + album_cover_url, release_date, album_type, source, profile_id) + VALUES (?,?,?,?,?,?,?,?,?)""", + (kw['id'], kw['name'], kw['artist'], kw['id'] + '_a', '', + kw['release_date'], kw.get('album_type', 'album'), kw.get('source', 'spotify'), 1)) + conn.commit() + + +def test_future_year_albums_excluded_released_kept(tmp_path): + db = MusicDatabase(database_path=str(tmp_path / "t.db")) + this_year = datetime.now().year + next_year = this_year + 1 + _insert(db, id='past1', name='Released A', artist='X', release_date=f'{this_year - 1}-03-01') + _insert(db, id='past2', name='Released B', artist='Y', release_date=f'{this_year}-01-15') + _insert(db, id='fut1', name='Announced', artist='Z', release_date=f'{next_year}-02-01') + _insert(db, id='fut2', name='Year Only Future', artist='W', release_date=str(next_year)) + _insert(db, id='blank', name='Unknown Date', artist='Q', release_date='') + + names_all = {a['album_name'] for a in db.get_discovery_recent_albums(limit=50, source='spotify')} + assert 'Announced' in names_all # without the flag, futures are present (and sort first) + + filtered = db.get_discovery_recent_albums(limit=50, source='spotify', exclude_future_years=True) + names = {a['album_name'] for a in filtered} + assert 'Announced' not in names # next-year album dropped + assert 'Year Only Future' not in names # YYYY-only future dropped + assert {'Released A', 'Released B'} <= names # released kept + assert 'Unknown Date' in names # blank date kept (treated as released) + + +def test_future_filter_does_not_over_trim_when_all_released(tmp_path): + db = MusicDatabase(database_path=str(tmp_path / "t2.db")) + this_year = datetime.now().year + for i in range(8): + _insert(db, id=f'r{i}', name=f'Album {i}', artist=f'A{i}', + release_date=f'{this_year}-0{(i % 9) + 1}-01') + filtered = db.get_discovery_recent_albums(limit=300, source='spotify', exclude_future_years=True) + assert len(filtered) == 8 # every released album survives, budget honored diff --git a/tests/test_watchlist_scanner_scan.py b/tests/test_watchlist_scanner_scan.py index 9eda637f..26a1a7f8 100644 --- a/tests/test_watchlist_scanner_scan.py +++ b/tests/test_watchlist_scanner_scan.py @@ -1171,7 +1171,7 @@ def test_curate_discovery_playlists_uses_source_priority_for_recent_albums(monke "avg_daily_plays": 0.0, "artist_play_counts": {}, }) - monkeypatch.setattr(scanner.database, "get_discovery_recent_albums", lambda limit, source, profile_id: [recent_album] if source == "deezer" else [], raising=False) + monkeypatch.setattr(scanner.database, "get_discovery_recent_albums", lambda limit, source, profile_id, exclude_future_years=False: [recent_album] if source == "deezer" else [], raising=False) monkeypatch.setattr(scanner.database, "get_discovery_pool_tracks", lambda *args, **kwargs: [discovery_track] if kwargs.get("source") == "deezer" else [], raising=False) monkeypatch.setattr(scanner.database, "save_curated_playlist", lambda key, tracks, profile_id=1: saved_playlists.append((key, list(tracks))) or True, raising=False) monkeypatch.setattr(scanner.database, "get_top_artists", lambda *args, **kwargs: [], raising=False) diff --git a/web_server.py b/web_server.py index 4e330c92..0c81f2f3 100644 --- a/web_server.py +++ b/web_server.py @@ -29083,6 +29083,96 @@ def get_discover_similar_artists(): return jsonify({"success": False, "error": str(e)}), 500 +@app.route('/api/discover/listening-recommendations', methods=['GET']) +def get_discover_listening_recommendations(): + """#913: artists you'd love based on what you actually LISTEN to (play-weighted). + + Distinct from /api/discover/similar-artists (which is driven by your whole library / + watchlist): this is seeded by your most-PLAYED artists, consensus-ranked across the + similar-artist graph, and recency-boosted. The heavy lifting + storage happen during the + watchlist scan (core.watchlist_scanner._build_listening_recommendations -> the + 'listening_recs_artists' metadata key); this endpoint just reshapes the stored list to the + same card shape the recommended-artists row already renders. Read-only, fail-soft. + """ + try: + database = get_database() + active_source = _get_active_discovery_source() + raw = database.get_metadata('listening_recs_artists') + if not raw: + return jsonify({"success": True, "artists": [], "source": active_source, "count": 0}) + try: + stored = json.loads(raw) or [] + except (ValueError, TypeError): + stored = [] + + result_artists = [] + for a in stored: + name = a.get('name') + if not name: + continue + if active_source == 'spotify': + artist_id = a.get('spotify_artist_id') + elif active_source == 'deezer': + artist_id = a.get('deezer_artist_id') or a.get('itunes_artist_id') + else: + artist_id = a.get('itunes_artist_id') + entry = { + "artist_id": artist_id, + "spotify_artist_id": a.get('spotify_artist_id'), + "itunes_artist_id": a.get('itunes_artist_id'), + "deezer_artist_id": a.get('deezer_artist_id'), + "artist_name": name, + "seed_count": a.get('seed_count'), + "source": active_source, + } + img = a.get('image_url') + if img: + entry["image_url"] = fix_artist_image_url(img) + if a.get('genres'): + entry["genres"] = a['genres'][:3] + # "because you listen to X, Y, Z" — the most-played artists that point here. + if a.get('seeds'): + entry["because"] = a['seeds'] + result_artists.append(entry) + + return jsonify({ + "success": True, + "artists": result_artists, + "source": active_source, + "count": len(result_artists), + }) + except Exception as e: + logger.error(f"Error getting listening recommendations: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/discover/personalized/listening-mix', methods=['GET']) +def get_discover_listening_mix(): + """#913: the "Listening Mix" playlist row — a playable track mix from the artists you'd + love based on what you actually listen to. + + The tracks are built during the watchlist scan (core.watchlist_scanner + ._build_listening_recommendations -> the 'listening_recs_tracks_full' metadata key) as full + render-ready dicts, so this endpoint just hands them back — no discovery-pool re-hydration, + which means it can't shrink when the pool rotates (the failure mode Fresh Tape/Archives hit). + Same {success, tracks} shape renderCompactPlaylist + the sync/download chains expect. + """ + try: + database = get_database() + active_source = _get_active_discovery_source() + raw = database.get_metadata('listening_recs_tracks_full') + tracks = [] + if raw: + try: + tracks = json.loads(raw) or [] + except (ValueError, TypeError): + tracks = [] + return jsonify({"success": True, "tracks": tracks, "source": active_source}) + except Exception as e: + logger.error(f"Error getting listening mix: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + @app.route('/api/discover/similar-artists/enrich', methods=['POST']) def enrich_similar_artists(): """Enrich a batch of artist IDs with images/genres from Spotify or iTunes. diff --git a/webui/index.html b/webui/index.html index 2d0e6fe3..1b83fae9 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3133,6 +3133,53 @@
+ + + + + +