From 97b40cbd432a10c2b7dbf1ec98406703c1559407 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 10 Jun 2026 19:40:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(verification):=20review=20queue=20?= =?UTF-8?q?=E2=80=94=20listen/compare/approve/delete=20unverified=20downlo?= =?UTF-8?q?ads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ⚠ 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//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 --- core/matching/verification_status.py | 5 +- core/repair_jobs/acoustid_scanner.py | 7 +++ database/music_database.py | 3 +- tests/test_acoustid_scanner.py | 20 ++++++++ web_server.py | 70 +++++++++++++++++++++++++++ webui/static/downloads.js | 2 + webui/static/pages-extra.js | 71 ++++++++++++++++++++++++++++ webui/static/style.css | 7 +++ 8 files changed, 183 insertions(+), 2 deletions(-) diff --git a/core/matching/verification_status.py b/core/matching/verification_status.py index 4b7569c9..05be1050 100644 --- a/core/matching/verification_status.py +++ b/core/matching/verification_status.py @@ -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' diff --git a/core/repair_jobs/acoustid_scanner.py b/core/repair_jobs/acoustid_scanner.py index ecf1bd3d..dd7db7af 100644 --- a/core/repair_jobs/acoustid_scanner.py +++ b/core/repair_jobs/acoustid_scanner.py @@ -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: diff --git a/database/music_database.py b/database/music_database.py index 2547d740..f131a03b 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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) diff --git a/tests/test_acoustid_scanner.py b/tests/test_acoustid_scanner.py index ce04b82f..089d594e 100644 --- a/tests/test_acoustid_scanner.py +++ b/tests/test_acoustid_scanner.py @@ -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 == [] diff --git a/web_server.py b/web_server.py index 54c47f33..29feac4d 100644 --- a/web_server.py +++ b/web_server.py @@ -7512,6 +7512,76 @@ def stream_quarantine_item(entry_id): return jsonify({"error": str(e)}), 500 +@app.route('/api/verification//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//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//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//recover', methods=['POST']) def recover_quarantine_item(entry_id): """Fallback for legacy thin sidecars: move file into Staging so the user diff --git a/webui/static/downloads.js b/webui/static/downloads.js index ae58ee4e..8a50d453 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -3689,6 +3689,8 @@ function processModalStatusUpdate(playlistId, data) { statusText += ' '; } else if (task.verification_status === 'verified') { statusText += ' '; + } else if (task.verification_status === 'human_verified') { + statusText += ' 🛡✔'; } completedCount++; break; diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index 5581afe5..141eabfb 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -2359,9 +2359,78 @@ function _adlVerifBadge(dl) { if (dl.verification_status === 'verified') { return ' '; } + if (dl.verification_status === 'human_verified') { + return ' 🛡✔'; + } return ''; } +// ---- Verification review queue (the ⚠ Unverified filter) ---- +let _verifAudio = null; +let _verifAudioId = null; + +function verifHistoryId(dl) { + // Persistent history rows carry task_id 'history-'. + 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 `
+ + + + +
`; +} + +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() { ${statusLabel}${_adlVerifBadge(dl)}${dl.retry_info && (statusClass === 'active' || statusClass === 'queued') ? ` 🔁 ${_adlEsc(String(dl.retry_info))}` : ''} + ${_adlReviewActions(dl)} ${cancelBtnHtml} `; } diff --git a/webui/static/style.css b/webui/static/style.css index 7e74308a..35567a5d 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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); }