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:
parent
e8cc7ca2c8
commit
e32b4ec727
2 changed files with 31 additions and 14 deletions
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue