fix(quarantine+quality): persistent group toggle, better alt-row UI, quality upgrade default scope

Quarantine grouping:
- First candidate shown as normal row; others hidden under a "▾ N more"
  button inline in the actions bar — no separate header row
- Group open state tracked in _verifQuarOpenGroups (Set), survives
  periodic re-renders so the list no longer auto-collapses

Quality Upgrade Finder:
- Default scope changed from 'watchlist' to 'all' so it scans the whole
  library when no scope is explicitly configured
- _get_settings rewritten to read the full settings dict at once
  (same pattern as QualityUpgradeScannerJob) to fix silent read failures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dev 2026-06-24 15:26:41 +02:00
parent e4ba27d8b3
commit ef0b68a973
3 changed files with 48 additions and 36 deletions

View file

@ -476,25 +476,26 @@ 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, 'require_top_target': False} default_settings = {'scope': 'all', '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]} setting_options = {'scope': ['all', 'watchlist'], '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]:
cfg = context.config_manager merged = dict(self.default_settings)
scope = 'watchlist' if context.config_manager:
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: try:
min_conf = float(cfg.get(self.get_config_key('settings.min_confidence'), 0.7)) cfg = context.config_manager.get(f'repair.jobs.{self.job_id}.settings', {})
except (TypeError, ValueError): if isinstance(cfg, dict):
min_conf = 0.7 merged.update(cfg)
deep_verify = cfg.get(self.get_config_key('settings.deep_audio_verify'), False) is True except Exception as e:
require_top = cfg.get(self.get_config_key('settings.require_top_target'), False) is True logger.debug("settings read failed: %s", e)
return {'scope': scope, 'min_confidence': min_conf, 'deep_audio_verify': deep_verify, 'require_top_target': require_top} try:
merged['min_confidence'] = float(merged.get('min_confidence', 0.7))
except (TypeError, ValueError):
merged['min_confidence'] = 0.7
merged['deep_audio_verify'] = merged.get('deep_audio_verify') is True
merged['require_top_target'] = merged.get('require_top_target') is True
return merged
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()

View file

@ -2611,6 +2611,7 @@ let _verifQuarLoading = false;
// Expanded 🔍 detail panels, keyed by quarantine entry id — survives the // Expanded 🔍 detail panels, keyed by quarantine entry id — survives the
// polling re-render (which rebuilds the rows every few seconds). // polling re-render (which rebuilds the rows every few seconds).
const _verifQuarOpenDetails = new Set(); const _verifQuarOpenDetails = new Set();
const _verifQuarOpenGroups = new Set(); // group keys whose alt-members are expanded
// null = not fetched yet (assume enabled). Without an AcoustID API key // null = not fetched yet (assume enabled). Without an AcoustID API key
// nothing ever gets a verification status, so the review queue collapses // nothing ever gets a verification status, so the review queue collapses
// to quarantine-only. // to quarantine-only.
@ -2668,7 +2669,7 @@ const _VERIF_QUAR_TRIGGERS = {
bit_depth: ['BIT DEPTH FILTER', 'verif-rb-int'], bit_depth: ['BIT DEPTH FILTER', 'verif-rb-int'],
}; };
function _verifQuarRowHtml(q, idx) { function _verifQuarRowHtml(q, idx, extraAction = '') {
const title = _adlEsc(q.expected_track || q.original_filename || q.filename || 'Unknown file'); 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 meta = [_adlEsc(q.expected_artist || ''), _adlEsc(q.original_filename || '')].filter(Boolean).join(' — ');
const [trigLabel, trigClass] = _VERIF_QUAR_TRIGGERS[q.trigger] || ['QUARANTINED', 'verif-rb-unv']; const [trigLabel, trigClass] = _VERIF_QUAR_TRIGGERS[q.trigger] || ['QUARANTINED', 'verif-rb-unv'];
@ -2702,10 +2703,21 @@ function _verifQuarRowHtml(q, idx) {
<button class="verif-act" onclick="verifQuarAudit(${idx})" title="Open the audit trail for this quarantined file (details, embedded tags, lyrics)">🔍</button> <button class="verif-act" onclick="verifQuarAudit(${idx})" title="Open the audit trail for this quarantined file (details, embedded tags, lyrics)">🔍</button>
${approveBtn} ${approveBtn}
<button class="verif-act verif-act-del" onclick="verifQuarDelete(${idx}, this)" title="Delete the quarantined file permanently">🗑</button> <button class="verif-act verif-act-del" onclick="verifQuarDelete(${idx}, this)" title="Delete the quarantined file permanently">🗑</button>
${extraAction}
</div> </div>
</div>`; </div>`;
} }
function _verifQuarToggleGroup(btn) {
const key = btn.dataset.groupKey;
const open = !_verifQuarOpenGroups.has(key);
if (open) _verifQuarOpenGroups.add(key); else _verifQuarOpenGroups.delete(key);
const wrapper = btn.closest('.verif-quar-alt-wrapper');
if (wrapper) wrapper.querySelector('.verif-quar-alt-members')?.classList.toggle('vqg-open', open);
btn.classList.toggle('open', open);
btn.textContent = open ? `${btn.dataset.altCount} more` : `${btn.dataset.altCount} more`;
}
function _verifQuarRows() { function _verifQuarRows() {
if (!_verifQuarLoaded) return '<div class="adl-section-header">Loading quarantine…</div>'; if (!_verifQuarLoaded) return '<div class="adl-section-header">Loading quarantine…</div>';
if (!_verifQuarEntries.length) return ''; if (!_verifQuarEntries.length) return '';
@ -2718,20 +2730,20 @@ function _verifQuarRows() {
if (group.members.length === 1) { if (group.members.length === 1) {
html += _verifQuarRowHtml(group.members[0], idxById.get(group.members[0].id)); html += _verifQuarRowHtml(group.members[0], idxById.get(group.members[0].id));
} else { } else {
// First member shown as normal row; rest hidden under a toggle button.
const first = group.members[0]; const first = group.members[0];
const title = _adlEsc(first.expected_track || first.original_filename || 'Unknown'); const firstIdx = idxById.get(first.id);
const artist = first.expected_artist ? `${_adlEsc(first.expected_artist)}` : ''; const altCount = group.members.length - 1;
const n = group.members.length; const groupKey = group.key || first.id;
const groupId = `vqg-${_adlEsc(first.id)}`; const isOpen = _verifQuarOpenGroups.has(groupKey);
html += `<div class="verif-quar-group"> const altBtn = `<button class="verif-quar-alt-btn${isOpen ? ' open' : ''}" data-group-key="${_adlEsc(groupKey)}" data-alt-count="${altCount}" onclick="_verifQuarToggleGroup(this)" title="Show ${altCount} more alternative candidate${altCount === 1 ? '' : 's'} for this track">${isOpen ? '▴' : '▾'} ${altCount} more</button>`;
<div class="verif-quar-group-hdr" onclick="document.getElementById('${groupId}').classList.toggle('vqg-open')" title="${n} alternative candidates quarantined for this track"> html += `<div class="verif-quar-alt-wrapper">`;
<span class="verif-quar-group-lbl">${title}${artist}</span> html += _verifQuarRowHtml(first, firstIdx, altBtn);
<span class="verif-quar-group-cnt">${n} alternatives &#x25BE;</span> html += `<div class="verif-quar-alt-members${isOpen ? ' vqg-open' : ''}">`;
</div> for (let i = 1; i < group.members.length; i++) {
<div class="verif-quar-group-members" id="${groupId}"> html += _verifQuarRowHtml(group.members[i], idxById.get(group.members[i].id));
${group.members.map(m => _verifQuarRowHtml(m, idxById.get(m.id))).join('')} }
</div> html += `</div></div>`;
</div>`;
} }
} }
return html; return html;

View file

@ -68215,13 +68215,12 @@ body.em-scroll-lock { overflow: hidden; }
.verif-banner-spacer { flex: 1; } .verif-banner-spacer { flex: 1; }
.verif-bulk-danger { border-color: rgba(248,113,113,0.4) !important; color: #f87171 !important; } .verif-bulk-danger { border-color: rgba(248,113,113,0.4) !important; color: #f87171 !important; }
.verif-quar-group { margin-bottom: 4px; } .verif-quar-alt-wrapper { display: contents; }
.verif-quar-group-hdr { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 6px; cursor: pointer; user-select: none; } .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-group-hdr:hover { background: rgba(255,255,255,0.07); } .verif-quar-alt-btn:hover { background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.8); }
.verif-quar-group-lbl { flex: 1; font-size: 13px; font-weight: 500; color: rgba(255,255,255,0.85); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .verif-quar-alt-btn.open { color: rgba(255,255,255,0.75); border-color: rgba(255,255,255,0.25); }
.verif-quar-group-cnt { font-size: 12px; color: rgba(255,255,255,0.45); white-space: nowrap; } .verif-quar-alt-members { display: none; padding-left: 20px; border-left: 2px solid rgba(255,255,255,0.07); margin-left: 8px; margin-bottom: 2px; }
.verif-quar-group-members { display: none; padding-left: 12px; padding-top: 2px; } .verif-quar-alt-members.vqg-open { display: block; }
.verif-quar-group-members.vqg-open { display: block; }
/* ── Security settings: grouped sub-sections + dependency visuals ── */ /* ── Security settings: grouped sub-sections + dependency visuals ── */
.security-subgroup { .security-subgroup {