feat(verification): review queue — listen/compare/approve/delete unverified downloads

- ⚠ Unverified filter rows gain actions: inline play (range-streamed from the
  history file path, server-side only), YouTube compare, Approve -> new
  human_verified status (tag + history + tracks; AcoustID scanner skips these
  entirely), Delete (file + entry)
- API: /api/verification/<id>/stream|approve|delete (path only from DB row)
- backfill: history rows with acoustid_result='fail' that exist at all were
  imported despite the failure = force_imported (covers pre-fix fallback
  imports like the user's 'My Ordinary Life')

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dev 2026-06-10 19:40:07 +02:00
parent 41536384c3
commit 97b40cbd43
8 changed files with 183 additions and 2 deletions

View file

@ -19,8 +19,11 @@ Quarantined files are never imported, so they carry no status.
VERIFIED = 'verified'
UNVERIFIED = 'unverified'
FORCE_IMPORTED = 'force_imported'
# Set by the user via the review queue ("yes, this file IS the right track").
# Outranks machine states: the scanner skips these entirely.
HUMAN_VERIFIED = 'human_verified'
ALL_STATUSES = (VERIFIED, UNVERIFIED, FORCE_IMPORTED)
ALL_STATUSES = (VERIFIED, UNVERIFIED, FORCE_IMPORTED, HUMAN_VERIFIED)
# The file tag name (Vorbis comment key / ID3 TXXX desc / MP4 freeform).
TAG_NAME = 'SOULSYNC_VERIFICATION'

View file

@ -237,6 +237,13 @@ class AcoustIDScannerJob(RepairJob):
file_verif_status = (_rft(fpath) or {}).get('verification_status')
except Exception:
pass
if file_verif_status == 'human_verified':
# The user explicitly confirmed this file via the review queue —
# never second-guess a human decision.
if context.report_progress:
context.report_progress(
log_line=f'Skipped (human-verified): {fname}', log_type='skip')
return
if file_verif_status == 'force_imported' and \
self._get_settings(context).get('skip_force_imported', False):
if context.report_progress:

View file

@ -651,9 +651,10 @@ class MusicDatabase:
CASE acoustid_result
WHEN 'pass' THEN 'verified'
WHEN 'skip' THEN 'unverified'
WHEN 'fail' THEN 'force_imported'
END
WHERE verification_status IS NULL
AND acoustid_result IN ('pass', 'skip')
AND acoustid_result IN ('pass', 'skip', 'fail')
""")
if cursor.rowcount:
logger.info("Backfilled verification_status from acoustid_result (%d rows)", cursor.rowcount)

View file

@ -868,3 +868,23 @@ def test_force_imported_mismatch_is_reported_as_informational(monkeypatch):
def test_force_imported_can_be_skipped_via_setting(monkeypatch):
captured = _force_imported_scan(monkeypatch, skip_setting=True)
assert captured == []
def test_human_verified_files_are_never_scanned(monkeypatch):
import core.repair_jobs.acoustid_scanner as scanner_mod
monkeypatch.setattr(scanner_mod, "_resolve_expected_artist_aliases",
lambda name: [], raising=False)
monkeypatch.setattr('core.tag_writer.read_file_tags',
lambda fpath: {'artist': None, 'verification_status': 'human_verified'})
job = AcoustIDScannerJob()
captured = []
context = _make_finding_capturing_context(
track_row=("7", "T", "A", "/music/t.flac", 1, "Al", None, None),
captured=captured)
fake = SimpleNamespace(fingerprint_and_lookup=lambda f: {
'best_score': 0.99,
'recordings': [{'title': 'Totally Different', 'artist': 'Metallica'}]})
job._scan_file('/music/t.flac', '7', {'title': 'T', 'artist': 'A'},
fake, context, JobResultStub(),
fp_threshold=0.85, title_threshold=0.85, artist_threshold=0.6)
assert captured == []

View file

@ -7512,6 +7512,76 @@ def stream_quarantine_item(entry_id):
return jsonify({"error": str(e)}), 500
@app.route('/api/verification/<int:history_id>/stream', methods=['GET'])
def stream_verification_item(history_id):
"""Stream a completed download for the verification review queue (listen
before approving). Path comes ONLY from the history row no client paths."""
try:
rows = get_database().get_library_history_rows_by_ids([history_id])
if not rows or not rows[0].get('file_path'):
return jsonify({"error": "History entry or file path not found"}), 404
file_path = rows[0]['file_path']
if not os.path.exists(file_path):
return jsonify({"error": "File not found on disk"}), 404
ext = os.path.splitext(file_path)[1].lower().lstrip('.')
mimetype = _AUDIO_MIME_TYPES.get(ext, 'audio/mpeg')
return _serve_audio_file_with_range(file_path, mimetype_override=mimetype)
except Exception as e:
logger.error(f"[Verification] Error streaming history {history_id}: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/verification/<int:history_id>/approve', methods=['POST'])
def approve_verification_item(history_id):
"""User confirmed the file IS the right track: set human_verified on the
history row, the file tag, and (best-effort) the tracks row. The AcoustID
scanner skips human-verified files entirely."""
try:
from core.matching.verification_status import HUMAN_VERIFIED
from core.tag_writer import write_verification_status
db = get_database()
rows = db.get_library_history_rows_by_ids([history_id])
if not rows:
return jsonify({"success": False, "error": "History entry not found"}), 404
file_path = rows[0].get('file_path') or ''
conn = db._get_connection()
conn.cursor().execute(
"UPDATE library_history SET verification_status = ? WHERE id = ?",
(HUMAN_VERIFIED, history_id))
if file_path:
conn.cursor().execute(
"UPDATE tracks SET verification_status = ? WHERE file_path = ?",
(HUMAN_VERIFIED, file_path))
conn.commit()
tag_written = bool(file_path) and write_verification_status(file_path, HUMAN_VERIFIED)
return jsonify({"success": True, "tag_written": tag_written})
except Exception as e:
logger.error(f"[Verification] Approve failed for {history_id}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/verification/<int:history_id>/delete', methods=['POST'])
def delete_verification_item(history_id):
"""User decided the file is wrong: delete it from disk and drop the
history row (the media-server mirror cleans the tracks row on next scan)."""
try:
db = get_database()
rows = db.get_library_history_rows_by_ids([history_id])
if not rows:
return jsonify({"success": False, "error": "History entry not found"}), 404
file_path = rows[0].get('file_path') or ''
file_deleted = False
if file_path and os.path.exists(file_path):
os.remove(file_path)
file_deleted = True
logger.info(f"[Verification] Deleted rejected file: {file_path}")
db.delete_library_history_rows([history_id])
return jsonify({"success": True, "file_deleted": file_deleted})
except Exception as e:
logger.error(f"[Verification] Delete failed for {history_id}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/quarantine/<entry_id>/recover', methods=['POST'])
def recover_quarantine_item(entry_id):
"""Fallback for legacy thin sidecars: move file into Staging so the user

View file

@ -3689,6 +3689,8 @@ function processModalStatusUpdate(playlistId, data) {
statusText += ' <span class="verif-badge verif-unverified" title="Imported but not hard-verified (AcoustID could not confirm — e.g. cross-script metadata or no fingerprint match).">⚠</span>';
} else if (task.verification_status === 'verified') {
statusText += ' <span class="verif-badge verif-ok" title="AcoustID verified: audio fingerprint matches the expected track.">✔</span>';
} else if (task.verification_status === 'human_verified') {
statusText += ' <span class="verif-badge verif-human" title="Human verified: you confirmed this file is the right track.">🛡✔</span>';
}
completedCount++;
break;

View file

@ -2359,9 +2359,78 @@ function _adlVerifBadge(dl) {
if (dl.verification_status === 'verified') {
return ' <span class="verif-badge verif-ok" title="AcoustID verified: audio fingerprint matches the expected track.">✔</span>';
}
if (dl.verification_status === 'human_verified') {
return ' <span class="verif-badge verif-human" title="Human verified: you confirmed this file is the right track. The AcoustID scanner skips it.">🛡✔</span>';
}
return '';
}
// ---- Verification review queue (the ⚠ Unverified filter) ----
let _verifAudio = null;
let _verifAudioId = null;
function verifHistoryId(dl) {
// Persistent history rows carry task_id 'history-<dbid>'.
if (!dl.is_persistent_history || !dl.task_id) return null;
const m = String(dl.task_id).match(/^history-(\d+)$/);
return m ? m[1] : null;
}
function _adlReviewActions(dl) {
if (_adlFilter !== 'unverified') return '';
const hid = verifHistoryId(dl);
if (!hid) return '';
const q = encodeURIComponent(`${dl.artist || ''} ${dl.title || ''}`.trim());
const playing = _verifAudioId === hid ? ' playing' : '';
return `<div class="verif-actions" onclick="event.stopPropagation()">
<button class="verif-act verif-act-play${playing}" onclick="verifTogglePlay('${hid}', this)" title="Listen to the downloaded file"></button>
<button class="verif-act" onclick="window.open('https://www.youtube.com/results?search_query=${q}','_blank')" title="Open a YouTube search for this track in a new tab — compare side by side">YT</button>
<button class="verif-act verif-act-ok" onclick="verifApprove('${hid}', this)" title="Approve: mark as human-verified (tag + DB). The AcoustID scanner will skip it."></button>
<button class="verif-act verif-act-del" onclick="verifDelete('${hid}', this)" title="Wrong file: delete it from disk and remove this entry">🗑</button>
</div>`;
}
function verifTogglePlay(hid, btn) {
if (_verifAudio && _verifAudioId === hid) {
_verifAudio.pause(); _verifAudio = null; _verifAudioId = null;
if (btn) { btn.textContent = '▶'; btn.classList.remove('playing'); }
return;
}
if (_verifAudio) { try { _verifAudio.pause(); } catch (e) {} }
document.querySelectorAll('.verif-act-play.playing').forEach(b => { b.textContent = '▶'; b.classList.remove('playing'); });
_verifAudio = new Audio(`/api/verification/${hid}/stream`);
_verifAudioId = hid;
_verifAudio.play().catch(() => { showToast && showToast('Could not play file', 'error'); });
_verifAudio.onended = () => { _verifAudioId = null; if (btn) { btn.textContent = '▶'; btn.classList.remove('playing'); } };
if (btn) { btn.textContent = '⏸'; btn.classList.add('playing'); }
}
async function verifApprove(hid, btn) {
if (btn) btn.disabled = true;
try {
const r = await fetch(`/api/verification/${hid}/approve`, { method: 'POST' });
const d = await r.json();
if (d.success) { showToast && showToast('Marked as human-verified 🛡✔', 'success'); _adlFetch(); }
else showToast && showToast(d.error || 'Approve failed', 'error');
} catch (e) { showToast && showToast('Approve failed', 'error'); }
if (btn) btn.disabled = false;
}
async function verifDelete(hid, btn) {
if (!confirm('Delete this file from disk and remove the entry?')) return;
if (btn) btn.disabled = true;
try {
const r = await fetch(`/api/verification/${hid}/delete`, { method: 'POST' });
const d = await r.json();
if (d.success) { showToast && showToast('File deleted', 'success'); _adlFetch(); }
else showToast && showToast(d.error || 'Delete failed', 'error');
} catch (e) { showToast && showToast('Delete failed', 'error'); }
if (btn) btn.disabled = false;
}
window.verifTogglePlay = verifTogglePlay;
window.verifApprove = verifApprove;
window.verifDelete = verifDelete;
function _adlRender() {
const list = document.getElementById('adl-list');
const empty = document.getElementById('adl-empty');
@ -2387,6 +2456,7 @@ function _adlRender() {
else if (_adlFilter === 'unverified') filtered = filtered.filter(d =>
completedStatuses.includes(d.status) &&
(d.verification_status === 'unverified' || d.verification_status === 'force_imported'));
// (review banner injected below when this filter is active)
else if (_adlFilter === 'failed') filtered = filtered.filter(d => failedStatuses.includes(d.status));
const completedN = _adlData.filter(d =>
@ -2514,6 +2584,7 @@ function _adlRender() {
<span class="adl-status-dot ${statusClass}"></span>
${statusLabel}${_adlVerifBadge(dl)}${dl.retry_info && (statusClass === 'active' || statusClass === 'queued') ? ` <span class="adl-retry-info" title="Retry engine: trying the next-best candidate (attempt ${_adlEsc(String(dl.retry_info))}${dl.retry_trigger ? ', triggered by ' + _adlEsc(dl.retry_trigger) : ''})">🔁 ${_adlEsc(String(dl.retry_info))}</span>` : ''}
</div>
${_adlReviewActions(dl)}
${cancelBtnHtml}
</div>`;
}

View file

@ -67899,3 +67899,10 @@ body.em-scroll-lock { overflow: hidden; }
.verif-badge.verif-unverified { color: #f1c40f; background: rgba(241,196,15,0.14); }
.verif-badge.verif-force { color: #e67e22; background: rgba(230,126,34,0.16); }
.adl-retry-info { margin-left: 6px; font-size: 11px; color: #e67e22; cursor: help; }
.verif-badge.verif-human { color: #3498db; background: rgba(52,152,219,0.14); }
.verif-actions { display: inline-flex; gap: 6px; margin-left: 10px; align-items: center; }
.verif-act { border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.85); border-radius: 6px; padding: 2px 8px; font-size: 12px; cursor: pointer; line-height: 18px; }
.verif-act:hover { background: rgba(255,255,255,0.14); }
.verif-act-ok:hover { background: rgba(46,204,113,0.25); border-color: rgba(46,204,113,0.5); }
.verif-act-del:hover { background: rgba(231,76,60,0.25); border-color: rgba(231,76,60,0.5); }
.verif-act-play.playing { background: rgba(var(--accent-rgb),0.3); }