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:
parent
41536384c3
commit
97b40cbd43
8 changed files with 183 additions and 2 deletions
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue