From e32b4ec72770297d489e1835ce418ecd8df5eccd Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 24 Jun 2026 00:44:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(repair):=20require=5Ftop=5Ftarget=20option?= =?UTF-8?q?=20=E2=80=94=20flag=20files=20upgradeable=20to=20preferred=20qu?= =?UTF-8?q?ality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in setting (default off) to both Quality Upgrade jobs. When enabled, a file only counts as "good enough" if it meets the highest-priority target in the ranked profile — not just any target. Example: with [FLAC 24-bit, FLAC 16-bit], a 16-bit file is flagged as a candidate for upgrade even though it satisfies the profile's fallback target. Finding titles say "Upgradeable" (not "Below quality") and the description names the preferred target explicitly. - quality_upgrade.py (Finder): reads require_top_target from settings, builds check_targets = targets[:1] when on, improves finding description - quality_upgrade_scanner.py (flag-only): same option + matching finding title/description change Pairs naturally with the best_quality search mode on this branch: the scanner surfaces the candidates, the wishlist download picks the best available version across all sources. Co-Authored-By: Claude Sonnet 4.6 --- core/repair_jobs/quality_upgrade.py | 22 ++++++++++++++------ core/repair_jobs/quality_upgrade_scanner.py | 23 ++++++++++++++------- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/core/repair_jobs/quality_upgrade.py b/core/repair_jobs/quality_upgrade.py index b1b610ab..15734650 100644 --- a/core/repair_jobs/quality_upgrade.py +++ b/core/repair_jobs/quality_upgrade.py @@ -476,8 +476,8 @@ class QualityUpgradeJob(RepairJob): icon = 'repair-icon-lossy' default_enabled = False default_interval_hours = 168 - default_settings = {'scope': 'watchlist', 'min_confidence': 0.7, 'deep_audio_verify': False} - setting_options = {'scope': ['watchlist', 'all'], 'deep_audio_verify': [True, False]} + default_settings = {'scope': 'watchlist', 'min_confidence': 0.7, 'deep_audio_verify': False, 'require_top_target': False} + setting_options = {'scope': ['watchlist', 'all'], 'deep_audio_verify': [True, False], 'require_top_target': [True, False]} auto_fix = False def _get_settings(self, context: JobContext) -> Dict[str, Any]: @@ -485,6 +485,7 @@ class QualityUpgradeJob(RepairJob): scope = 'watchlist' min_conf = 0.7 deep_verify = False + require_top = False if cfg: scope = cfg.get(self.get_config_key('settings.scope'), 'watchlist') or 'watchlist' try: @@ -492,7 +493,8 @@ class QualityUpgradeJob(RepairJob): except (TypeError, ValueError): min_conf = 0.7 deep_verify = cfg.get(self.get_config_key('settings.deep_audio_verify'), False) is True - return {'scope': scope, 'min_confidence': min_conf, 'deep_audio_verify': deep_verify} + require_top = cfg.get(self.get_config_key('settings.require_top_target'), False) is True + return {'scope': scope, 'min_confidence': min_conf, 'deep_audio_verify': deep_verify, 'require_top_target': require_top} def _load_tracks(self, db: Any, scope: str) -> List[dict]: conn = db._get_connection() @@ -549,6 +551,7 @@ class QualityUpgradeJob(RepairJob): scope = settings['scope'] min_conf = settings['min_confidence'] deep_verify = settings['deep_audio_verify'] + require_top = settings['require_top_target'] # v3 quality: judge the REAL file (mutagen-measured bit depth / sample rate # / bitrate) against the profile's ranked targets — the SAME definition the @@ -562,6 +565,12 @@ 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 + try: tracks = self._load_tracks(db, scope) except Exception as e: @@ -633,7 +642,7 @@ 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, targets): + 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) @@ -737,8 +746,9 @@ class QualityUpgradeJob(RepairJob): file_path=file_path, title=f'Upgrade: {artist_name} - {title} ({current_label})', description=( - f'"{title}" by {artist_name} is {current_label}, below your preferred ' - f'quality. Best match: "{_track_name(best)}" via {source} ' + 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'. 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.'), details={ diff --git a/core/repair_jobs/quality_upgrade_scanner.py b/core/repair_jobs/quality_upgrade_scanner.py index 6df5f547..060f3375 100644 --- a/core/repair_jobs/quality_upgrade_scanner.py +++ b/core/repair_jobs/quality_upgrade_scanner.py @@ -68,9 +68,10 @@ class QualityUpgradeScannerJob(RepairJob): # deep_audio_verify default OFF: the ffmpeg decode is the CPU-heavy step. Most # users want the fast header-only quality pass; turn it on for a deep scan that # also catches broken/silent audio. (Matches the download pipeline's default.) - default_settings = {'library_tracks_only': False, 'deep_audio_verify': False} + default_settings = {'library_tracks_only': False, 'deep_audio_verify': False, 'require_top_target': False} setting_options = {'library_tracks_only': [True, False], - 'deep_audio_verify': [True, False]} + 'deep_audio_verify': [True, False], + 'require_top_target': [True, False]} auto_fix = False # User chooses fix action per finding def scan(self, context: JobContext) -> JobResult: @@ -147,6 +148,10 @@ class QualityUpgradeScannerJob(RepairJob): # it verifies the REAL audio, not just the metadata. OFF by default (the # decode is the CPU-heavy step); turn on for a deep scan. deep_verify = _settings.get('deep_audio_verify', False) + # require_top_target: flag files that meet a lower target but not the + # highest-priority one (e.g. 16-bit FLAC when 24-bit is preferred). + require_top = _settings.get('require_top_target', False) + check_targets = targets[:1] if require_top and len(targets) > 1 else targets probe_failed = 0 not_in_library = 0 @@ -203,7 +208,7 @@ class QualityUpgradeScannerJob(RepairJob): probe_failed += 1 result.skipped += 1 continue - elif not quality_meets_profile(aq, targets): + elif not quality_meets_profile(aq, check_targets): issue = 'below_profile' current_label = aq.label() else: @@ -222,11 +227,13 @@ class QualityUpgradeScannerJob(RepairJob): f'verification (ffmpeg): {broken_reason}') _severity = 'warning' else: - _title = f'Below quality: {disp_title} ({current_label})' - _desc = (f'"{disp_title}" by {disp_artist} is {current_label}, ' - f'which does not meet your quality profile ' - f'({", ".join(target_labels[:3])}' - f'{"…" if len(target_labels) > 3 else ""}).') + _pref = targets[0].label if require_top and len(targets) > 1 else None + _title = f'{"Upgradeable" if _pref else "Below quality"}: {disp_title} ({current_label})' + _desc = (f'"{disp_title}" by {disp_artist} is {current_label}' + + (f', below your preferred quality ({_pref}).' if _pref else + f', which does not meet your quality profile ' + f'({", ".join(target_labels[:3])}' + f'{"…" if len(target_labels) > 3 else ""}).')) _severity = 'info' if context.report_progress: