feat(repair): require_top_target option — flag files upgradeable to preferred quality

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 <noreply@anthropic.com>
This commit is contained in:
dev 2026-06-24 00:44:24 +02:00
parent e8cc7ca2c8
commit e32b4ec727
2 changed files with 31 additions and 14 deletions

View file

@ -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={

View file

@ -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: