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'
|
icon = 'repair-icon-lossy'
|
||||||
default_enabled = False
|
default_enabled = False
|
||||||
default_interval_hours = 168
|
default_interval_hours = 168
|
||||||
default_settings = {'scope': 'watchlist', 'min_confidence': 0.7, 'deep_audio_verify': 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]}
|
setting_options = {'scope': ['watchlist', 'all'], 'deep_audio_verify': [True, False], 'require_top_target': [True, False]}
|
||||||
auto_fix = False
|
auto_fix = False
|
||||||
|
|
||||||
def _get_settings(self, context: JobContext) -> Dict[str, Any]:
|
def _get_settings(self, context: JobContext) -> Dict[str, Any]:
|
||||||
|
|
@ -485,6 +485,7 @@ class QualityUpgradeJob(RepairJob):
|
||||||
scope = 'watchlist'
|
scope = 'watchlist'
|
||||||
min_conf = 0.7
|
min_conf = 0.7
|
||||||
deep_verify = False
|
deep_verify = False
|
||||||
|
require_top = False
|
||||||
if cfg:
|
if cfg:
|
||||||
scope = cfg.get(self.get_config_key('settings.scope'), 'watchlist') or 'watchlist'
|
scope = cfg.get(self.get_config_key('settings.scope'), 'watchlist') or 'watchlist'
|
||||||
try:
|
try:
|
||||||
|
|
@ -492,7 +493,8 @@ class QualityUpgradeJob(RepairJob):
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
min_conf = 0.7
|
min_conf = 0.7
|
||||||
deep_verify = cfg.get(self.get_config_key('settings.deep_audio_verify'), False) is True
|
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]:
|
def _load_tracks(self, db: Any, scope: str) -> List[dict]:
|
||||||
conn = db._get_connection()
|
conn = db._get_connection()
|
||||||
|
|
@ -549,6 +551,7 @@ class QualityUpgradeJob(RepairJob):
|
||||||
scope = settings['scope']
|
scope = settings['scope']
|
||||||
min_conf = settings['min_confidence']
|
min_conf = settings['min_confidence']
|
||||||
deep_verify = settings['deep_audio_verify']
|
deep_verify = settings['deep_audio_verify']
|
||||||
|
require_top = settings['require_top_target']
|
||||||
|
|
||||||
# v3 quality: judge the REAL file (mutagen-measured bit depth / sample rate
|
# v3 quality: judge the REAL file (mutagen-measured bit depth / sample rate
|
||||||
# / bitrate) against the profile's ranked targets — the SAME definition the
|
# / 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")
|
logger.info("[Quality Upgrade] No quality targets in profile — nothing to flag")
|
||||||
return result
|
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:
|
try:
|
||||||
tracks = self._load_tracks(db, scope)
|
tracks = self._load_tracks(db, scope)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -633,7 +642,7 @@ class QualityUpgradeJob(RepairJob):
|
||||||
context.update_progress(i + 1, total)
|
context.update_progress(i + 1, total)
|
||||||
continue
|
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
|
result.skipped += 1
|
||||||
if context.update_progress and (i + 1) % 25 == 0:
|
if context.update_progress and (i + 1) % 25 == 0:
|
||||||
context.update_progress(i + 1, total)
|
context.update_progress(i + 1, total)
|
||||||
|
|
@ -737,8 +746,9 @@ class QualityUpgradeJob(RepairJob):
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
title=f'Upgrade: {artist_name} - {title} ({current_label})',
|
title=f'Upgrade: {artist_name} - {title} ({current_label})',
|
||||||
description=(
|
description=(
|
||||||
f'"{title}" by {artist_name} is {current_label}, below your preferred '
|
f'"{title}" by {artist_name} is {current_label}'
|
||||||
f'quality. Best match: "{_track_name(best)}" via {source} '
|
+ (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%}"}). '
|
f'({_MATCH_NOTE.get(matched_via, "matched") if matched_via != "search" else f"confidence {conf:.0%}"}). '
|
||||||
'Apply to add it to the wishlist.'),
|
'Apply to add it to the wishlist.'),
|
||||||
details={
|
details={
|
||||||
|
|
|
||||||
|
|
@ -68,9 +68,10 @@ class QualityUpgradeScannerJob(RepairJob):
|
||||||
# deep_audio_verify default OFF: the ffmpeg decode is the CPU-heavy step. Most
|
# 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
|
# 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.)
|
# 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],
|
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
|
auto_fix = False # User chooses fix action per finding
|
||||||
|
|
||||||
def scan(self, context: JobContext) -> JobResult:
|
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
|
# 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.
|
# decode is the CPU-heavy step); turn on for a deep scan.
|
||||||
deep_verify = _settings.get('deep_audio_verify', False)
|
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
|
probe_failed = 0
|
||||||
not_in_library = 0
|
not_in_library = 0
|
||||||
|
|
@ -203,7 +208,7 @@ class QualityUpgradeScannerJob(RepairJob):
|
||||||
probe_failed += 1
|
probe_failed += 1
|
||||||
result.skipped += 1
|
result.skipped += 1
|
||||||
continue
|
continue
|
||||||
elif not quality_meets_profile(aq, targets):
|
elif not quality_meets_profile(aq, check_targets):
|
||||||
issue = 'below_profile'
|
issue = 'below_profile'
|
||||||
current_label = aq.label()
|
current_label = aq.label()
|
||||||
else:
|
else:
|
||||||
|
|
@ -222,11 +227,13 @@ class QualityUpgradeScannerJob(RepairJob):
|
||||||
f'verification (ffmpeg): {broken_reason}')
|
f'verification (ffmpeg): {broken_reason}')
|
||||||
_severity = 'warning'
|
_severity = 'warning'
|
||||||
else:
|
else:
|
||||||
_title = f'Below quality: {disp_title} ({current_label})'
|
_pref = targets[0].label if require_top and len(targets) > 1 else None
|
||||||
_desc = (f'"{disp_title}" by {disp_artist} is {current_label}, '
|
_title = f'{"Upgradeable" if _pref else "Below quality"}: {disp_title} ({current_label})'
|
||||||
f'which does not meet your quality profile '
|
_desc = (f'"{disp_title}" by {disp_artist} is {current_label}'
|
||||||
f'({", ".join(target_labels[:3])}'
|
+ (f', below your preferred quality ({_pref}).' if _pref else
|
||||||
f'{"…" if len(target_labels) > 3 else ""}).')
|
f', which does not meet your quality profile '
|
||||||
|
f'({", ".join(target_labels[:3])}'
|
||||||
|
f'{"…" if len(target_labels) > 3 else ""}).'))
|
||||||
_severity = 'info'
|
_severity = 'info'
|
||||||
|
|
||||||
if context.report_progress:
|
if context.report_progress:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue