From 5896f2dcc6cb0be258fb51415dca0a3b57867738 Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 11 Jun 2026 01:09:05 +0200 Subject: [PATCH] fix: eager config load + check acoustid.enabled for verification pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On fresh page load the Downloads pill now immediately reflects whether Download Verification is enabled (calls _verifLoadConfig in loadActiveDownloadsPage instead of only on first filter click). Also changed /api/verification/config to check the `acoustid.enabled` toggle rather than the raw api_key string — matches the UI setting "Enable Download Verification". Co-Authored-By: Claude Sonnet 4.6 --- web_server.py | 361 ++++++++++++++++++++++++++-- webui/static/pages-extra.js | 460 ++++++++++++++++++++++++++++++++++-- 2 files changed, 782 insertions(+), 39 deletions(-) diff --git a/web_server.py b/web_server.py index 29feac4d..121455d6 100644 --- a/web_server.py +++ b/web_server.py @@ -7512,18 +7512,76 @@ def stream_quarantine_item(entry_id): return jsonify({"error": str(e)}), 500 +def _get_library_history_row(history_id): + """Fetch one full library_history row as a dict (or None).""" + conn = get_database()._get_connection() + cursor = conn.cursor() + cursor.execute("SELECT * FROM library_history WHERE id = ?", (history_id,)) + row = cursor.fetchone() + return dict(row) if row else None + + +def _resolve_history_audio_path(row): + """Resolve a library_history row to a playable on-disk file. + + The recorded path can go stale: Docker↔host prefix differences, or the + media server / organizer renaming files with exotic titles (e.g. + 凸】♀】♂】←Titan) after import. Fallback chain: + 1. the recorded path as-is, + 2. `_resolve_library_file_path` (transfer/download/library prefix swap), + 3. the tracks table — the media-server mirror knows the CURRENT path for + this title+artist even after a rename — resolved the same way. + """ + raw_path = (row.get('file_path') or '').strip() + if raw_path and os.path.exists(raw_path): + return raw_path + resolved = _resolve_library_file_path(raw_path) if raw_path else None + if resolved and os.path.exists(resolved): + return resolved + title = (row.get('title') or '').strip() + if not title: + return None + artist = (row.get('artist_name') or '').strip() + try: + conn = get_database()._get_connection() + cursor = conn.cursor() + cursor.execute( + "SELECT file_path FROM tracks WHERE file_path IS NOT NULL AND LOWER(title) = LOWER(?)", + (title,)) + candidates = [r[0] for r in cursor.fetchall() if r[0]] + except Exception as e: + logger.debug(f"[Verification] tracks-table path fallback failed: {e}") + return None + # Same-title collisions across artists exist (and delete() trusts this + # path), so be strict: when the row names an artist, only accept + # candidates whose path mentions it; otherwise only an unambiguous + # single candidate. + artist_l = artist.lower() + if artist_l: + candidates = [p for p in candidates if artist_l in p.lower()] + elif len(candidates) != 1: + return None + for cand in candidates: + cand_resolved = _resolve_library_file_path(cand) + if cand_resolved and os.path.exists(cand_resolved): + return cand_resolved + return None + + @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): + row = _get_library_history_row(history_id) + if not row: + return jsonify({"error": "History entry not found"}), 404 + file_path = _resolve_history_audio_path(row) + if not file_path: return jsonify({"error": "File not found on disk"}), 404 - ext = os.path.splitext(file_path)[1].lower().lstrip('.') + # _AUDIO_MIME_TYPES keys keep the dot ('.flac') — don't strip it, or + # everything falls back to audio/mpeg and FLAC playback breaks. + ext = os.path.splitext(file_path)[1].lower() mimetype = _AUDIO_MIME_TYPES.get(ext, 'audio/mpeg') return _serve_audio_file_with_range(file_path, mimetype_override=mimetype) except Exception as e: @@ -7531,6 +7589,264 @@ def stream_verification_item(history_id): return jsonify({"error": str(e)}), 500 +@app.route('/api/verification//entry', methods=['GET']) +def get_verification_entry(history_id): + """Full library_history row for one review-queue item — feeds the Audit + Trail modal when opened from the Downloads page (where the history-page + entry cache is not populated).""" + try: + row = _get_library_history_row(history_id) + if not row: + return jsonify({"success": False, "error": "History entry not found"}), 404 + return jsonify({"success": True, "entry": row}) + except Exception as e: + logger.error(f"[Verification] Entry fetch failed for {history_id}: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/verification/config', methods=['GET']) +def get_verification_config(): + """Whether AcoustID/download-verification is enabled — if not, the review + queue collapses to quarantine-only in the UI.""" + try: + enabled = bool(config_manager.get('acoustid.enabled', False)) + return jsonify({"success": True, "acoustid_enabled": enabled}) + except Exception as e: + return jsonify({"success": True, "acoustid_enabled": True, "error": str(e)}) + + +def _audio_file_duration_ms(path): + """Best-effort duration of an on-disk audio file (0 when unreadable). + mutagen detects the format from content, so this also works for + quarantined files whose extension was swapped to `.quarantined`.""" + try: + import mutagen + mf = mutagen.File(path) + if mf and mf.info and getattr(mf.info, 'length', 0): + return int(mf.info.length * 1000) + except Exception: + pass + return 0 + + +def _set_review_play_session(file_path, title, artist, album, mimetype=None): + """Point THIS listener's media-player session at a local file — same + mechanism as /api/library/play, so the bottom player UI drives playback + (seek/stop/volume) instead of an invisible Audio element.""" + sess = _current_stream_state() + with sess.lock: + sess.update({ + "status": "ready", + "progress": 100, + "track_info": { + "title": title or os.path.basename(file_path), + "artist": artist or 'Unknown Artist', + "album": album or '', + }, + "file_path": file_path, + "stream_url": None, + "error_message": None, + "is_library": True, + # Content-Type hint for /stream/audio — needed for quarantined + # files whose on-disk extension is `.quarantined`. Keyed to the + # exact path so a stale hint can never leak onto another file. + "mimetype_override": mimetype, + "mimetype_override_path": file_path if mimetype else None, + }) + + +@app.route('/api/verification//play', methods=['POST']) +def play_verification_item(history_id): + """Load the downloaded file into the media player (review queue ▶).""" + try: + row = _get_library_history_row(history_id) + if not row: + return jsonify({"success": False, "error": "History entry not found"}), 404 + file_path = _resolve_history_audio_path(row) + if not file_path: + return jsonify({"success": False, "error": "File not found on disk"}), 404 + _set_review_play_session( + file_path, row.get('title'), row.get('artist_name'), row.get('album_name')) + return jsonify({"success": True, "track_info": { + "title": row.get('title') or os.path.basename(file_path), + "artist": row.get('artist_name') or '', + "album": row.get('album_name') or '', + "image_url": row.get('thumb_url') or None, + }}) + except Exception as e: + logger.error(f"[Verification] Play failed for {history_id}: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/verification//compare-stream', methods=['POST']) +def compare_stream_verification_item(history_id): + """Find the expected track on Soulseek/streaming sources for an A/B + comparison — the SAME pipeline as the /search page play button, but fed + server-side so the local file's duration guides candidate ranking (a + missing duration lets e.g. 10-hour YouTube loops win and time out).""" + try: + row = _get_library_history_row(history_id) + if not row: + return jsonify({"success": False, "error": "History entry not found"}), 404 + local = _resolve_history_audio_path(row) + duration_ms = _audio_file_duration_ms(local) if local else 0 + result = _search_stream.stream_search_track( + track_name=row.get('title') or '', + artist_name=row.get('artist_name') or '', + album_name=row.get('album_name') or '', + duration_ms=duration_ms, + config_manager=config_manager, + download_orchestrator=download_orchestrator, + matching_engine=matching_engine, + run_async=run_async, + ) + if result is None: + return jsonify({"success": False, + "error": "No suitable stream candidate found"}), 404 + result['title'] = row.get('title') or '' + result['artist'] = row.get('artist_name') or '' + result['album'] = row.get('album_name') or '' + result['image_url'] = row.get('thumb_url') or None + return jsonify({"success": True, "result": result}) + except Exception as e: + logger.error(f"[Verification] Compare-stream failed for {history_id}: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +def _get_quarantine_entry(entry_id): + from core.imports.quarantine import list_quarantine_entries + for entry in list_quarantine_entries(_get_quarantine_dir()): + if entry.get('id') == entry_id: + return entry + return None + + +@app.route('/api/quarantine//play', methods=['POST']) +def play_quarantine_item(entry_id): + """Load a quarantined file into the media player (review queue ▶).""" + try: + from core.imports.quarantine import get_quarantine_entry_stream_info + info = get_quarantine_entry_stream_info(_get_quarantine_dir(), entry_id) + if info is None: + return jsonify({"success": False, "error": "Quarantined file not found"}), 404 + file_path, extension = info + entry = _get_quarantine_entry(entry_id) or {} + title = entry.get('expected_track') or entry.get('original_filename') or os.path.basename(file_path) + _set_review_play_session( + file_path, f"{title} (quarantined)", entry.get('expected_artist'), '', + mimetype=_AUDIO_MIME_TYPES.get(extension, 'audio/mpeg')) + return jsonify({"success": True, "track_info": { + "title": f"{title} (quarantined)", + "artist": entry.get('expected_artist') or '', + "album": '', + }}) + except Exception as e: + logger.error(f"[Quarantine] Play failed for {entry_id}: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/quarantine//compare-stream', methods=['POST']) +def compare_stream_quarantine_item(entry_id): + """Stream-search the EXPECTED track for a quarantined file (A/B compare), + using the quarantined file's duration to guide candidate ranking.""" + try: + from core.imports.quarantine import get_quarantine_entry_stream_info + entry = _get_quarantine_entry(entry_id) + if not entry: + return jsonify({"success": False, "error": "Quarantine entry not found"}), 404 + track_name = entry.get('expected_track') or '' + artist_name = entry.get('expected_artist') or '' + if not track_name: + return jsonify({"success": False, + "error": "Entry has no expected-track metadata"}), 400 + info = get_quarantine_entry_stream_info(_get_quarantine_dir(), entry_id) + duration_ms = _audio_file_duration_ms(info[0]) if info else 0 + result = _search_stream.stream_search_track( + track_name=track_name, + artist_name=artist_name, + album_name='', + duration_ms=duration_ms, + config_manager=config_manager, + download_orchestrator=download_orchestrator, + matching_engine=matching_engine, + run_async=run_async, + ) + if result is None: + return jsonify({"success": False, + "error": "No suitable stream candidate found"}), 404 + result['title'] = track_name + result['artist'] = artist_name + return jsonify({"success": True, "result": result}) + except Exception as e: + logger.error(f"[Quarantine] Compare-stream failed for {entry_id}: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/quarantine//entry', methods=['GET']) +def get_quarantine_audit_entry(entry_id): + """Synthesize a library_history-shaped entry from a quarantine sidecar so + the review queue opens the SAME Audit Trail modal for quarantined files + (they were never imported, so no history row exists). ``id`` is None on + purpose — the modal fetches tags/lyrics through ``_file_tags_url``.""" + try: + from core.imports.quarantine import ( + get_quarantine_entry_context, get_quarantine_entry_stream_info) + entry = _get_quarantine_entry(entry_id) + if not entry: + return jsonify({"success": False, "error": "Quarantine entry not found"}), 404 + ctx = get_quarantine_entry_context(_get_quarantine_dir(), entry_id) + info = get_quarantine_entry_stream_info(_get_quarantine_dir(), entry_id) + osr = ctx.get('original_search_result') if isinstance(ctx.get('original_search_result'), dict) else {} + username = (osr.get('username') or '') if isinstance(osr, dict) else '' + streaming = ('tidal', 'youtube', 'qobuz', 'hifi', 'deezer_dl', + 'lidarr', 'soundcloud', 'amazon') + ti = ctx.get('track_info') if isinstance(ctx.get('track_info'), dict) else {} + album_raw = ti.get('album', '') + album_name = album_raw.get('name', '') if isinstance(album_raw, dict) else str(album_raw or '') + synthetic = { + 'id': None, + 'event_type': 'download', + 'title': entry.get('expected_track') or entry.get('original_filename') or '', + 'artist_name': entry.get('expected_artist') or '', + 'album_name': album_name, + 'created_at': entry.get('timestamp') or '', + 'thumb_url': entry.get('thumb_url') or '', + 'file_path': info[0] if info else '', + 'quality': ctx.get('_audio_quality') or '', + 'download_source': username if username in streaming else ('soulseek' if username else ''), + 'source_filename': entry.get('source_filename') or '', + 'source_artist': (osr.get('artist') or '') if isinstance(osr, dict) else '', + 'source_track_title': (osr.get('title') or osr.get('name') or '') if isinstance(osr, dict) else '', + 'acoustid_result': 'fail' if entry.get('trigger') == 'acoustid' else None, + 'verification_status': None, + '_quarantined': True, + '_quarantine_reason': entry.get('reason') or '', + '_file_tags_url': f"/api/quarantine/{entry_id}/file-tags", + } + return jsonify({"success": True, "entry": synthetic}) + except Exception as e: + logger.error(f"[Quarantine] Audit entry failed for {entry_id}: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/quarantine//file-tags', methods=['GET']) +def get_quarantine_file_tags(entry_id): + """Embedded tags of a quarantined file — feeds the Audit modal's Tags / + Lyrics tabs. mutagen detects the format from content, so the swapped + `.quarantined` extension is no obstacle.""" + try: + from core.imports.quarantine import get_quarantine_entry_stream_info + from core.library.file_tags import read_embedded_tags + info = get_quarantine_entry_stream_info(_get_quarantine_dir(), entry_id) + if info is None: + return jsonify({'success': False, 'error': 'Quarantined file not found'}), 404 + result = read_embedded_tags(info[0]) + return jsonify({'success': True, **result}) + except Exception as e: + logger.error(f"[Quarantine] File-tags failed for {entry_id}: {e}") + return jsonify({'success': False, '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 @@ -7540,20 +7856,22 @@ def approve_verification_item(history_id): 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: + row = _get_library_history_row(history_id) + if not row: return jsonify({"success": False, "error": "History entry not found"}), 404 - file_path = rows[0].get('file_path') or '' + file_path = row.get('file_path') or '' + on_disk = _resolve_history_audio_path(row) conn = db._get_connection() conn.cursor().execute( "UPDATE library_history SET verification_status = ? WHERE id = ?", (HUMAN_VERIFIED, history_id)) - if file_path: + # The tracks row may carry either the recorded or the resolved path. + for p in {p for p in (file_path, on_disk) if p}: conn.cursor().execute( "UPDATE tracks SET verification_status = ? WHERE file_path = ?", - (HUMAN_VERIFIED, file_path)) + (HUMAN_VERIFIED, p)) conn.commit() - tag_written = bool(file_path) and write_verification_status(file_path, HUMAN_VERIFIED) + tag_written = bool(on_disk) and write_verification_status(on_disk, HUMAN_VERIFIED) return jsonify({"success": True, "tag_written": tag_written}) except Exception as e: logger.error(f"[Verification] Approve failed for {history_id}: {e}") @@ -7566,15 +7884,15 @@ def delete_verification_item(history_id): 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: + row = _get_library_history_row(history_id) + if not row: return jsonify({"success": False, "error": "History entry not found"}), 404 - file_path = rows[0].get('file_path') or '' + on_disk = _resolve_history_audio_path(row) file_deleted = False - if file_path and os.path.exists(file_path): - os.remove(file_path) + if on_disk and os.path.exists(on_disk): + os.remove(on_disk) file_deleted = True - logger.info(f"[Verification] Deleted rejected file: {file_path}") + logger.info(f"[Verification] Deleted rejected file: {on_disk}") db.delete_library_history_rows([history_id]) return jsonify({"success": True, "file_deleted": file_deleted}) except Exception as e: @@ -12776,6 +13094,11 @@ def stream_audio(): stream_url = sess.get("stream_url") if not file_path and not stream_url: return jsonify({"error": "No audio file ready for streaming"}), 404 + # Content-Type hint set by the quarantine player (on-disk extension + # is `.quarantined`). Path-keyed so it can't leak onto other files. + mimetype_override = (sess.get("mimetype_override") + if sess.get("mimetype_override_path") == file_path + else None) # Library track played via the media server's stream API (#809). if stream_url: @@ -12783,7 +13106,7 @@ def stream_audio(): return _proxy_stream_url_with_range(stream_url) logger.info(f"Serving audio file: {os.path.basename(file_path)}") - return _serve_audio_file_with_range(file_path) + return _serve_audio_file_with_range(file_path, mimetype_override=mimetype_override) except Exception as e: logger.error(f"Error serving audio file: {e}") return jsonify({"error": str(e)}), 500 diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index 141eabfb..a866322d 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -2282,6 +2282,7 @@ function _adlRenderBatchSummary(activeBatches) { } function loadActiveDownloadsPage() { + _verifLoadConfig(); _adlFetch(); _adlFetchBatchHistory(); // Poll downloads every 2 seconds, history every 60 seconds @@ -2365,9 +2366,7 @@ function _adlVerifBadge(dl) { return ''; } -// ---- Verification review queue (the ⚠ Unverified filter) ---- -let _verifAudio = null; -let _verifAudioId = null; +// ---- Verification review queue (the ⚠ Unverified/Quarantine filter) ---- function verifHistoryId(dl) { // Persistent history rows carry task_id 'history-'. @@ -2376,33 +2375,95 @@ function verifHistoryId(dl) { return m ? m[1] : null; } +function _verifTimeAgo(iso) { + return (typeof formatHistoryTime === 'function' && iso) ? formatHistoryTime(iso) : ''; +} + +function _verifReasonBadge(dl) { + // Glanceable badge in the style of the library-history quarantine tab. + if (dl.verification_status === 'force_imported') { + return 'FORCE-IMPORTED'; + } + if (dl.verification_status === 'unverified') { + return 'ACOUSTID UNCONFIRMED'; + } + return ''; +} + 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' : ''; + const timeAgo = _verifTimeAgo(dl.created_at); return `
- - + ${_verifReasonBadge(dl)} + ${timeAgo ? `${timeAgo}` : ''} + + +
`; } -function verifTogglePlay(hid, btn) { - if (_verifAudio && _verifAudioId === hid) { - _verifAudio.pause(); _verifAudio = null; _verifAudioId = null; - if (btn) { btn.textContent = '▶'; btn.classList.remove('playing'); } - return; +async function verifPlay(hid) { + // Plays the LOCAL file through the global media player (same machinery as + // library playback) — full player UI with seek/stop instead of an + // invisible Audio element that re-renders wiped. + const dl = _adlData.find(d => verifHistoryId(d) === String(hid)); + try { + if (typeof setTrackInfo === 'function') { + setTrackInfo({ + title: (dl && dl.title) || 'Review track', + artist: (dl && dl.artist) || '', + album: (dl && dl.album) || '', + is_library: true, + image_url: (dl && dl.artwork) || null, + }); + } + if (typeof showLoadingAnimation === 'function') showLoadingAnimation(); + const r = await fetch(`/api/verification/${hid}/play`, { method: 'POST' }); + const d = await r.json(); + if (!d.success) throw new Error(d.error || 'Playback failed'); + await startAudioPlayback(); + } catch (e) { + if (typeof hideLoadingAnimation === 'function') hideLoadingAnimation(); + showToast && showToast('Playback failed: ' + e.message, 'error'); + } +} + +async function verifCompare(hid, btn) { + // Same pipeline as the /search page play button — run server-side so the + // local file's duration guides candidate ranking (avoids e.g. 10-hour + // YouTube loops winning the match and timing out). + if (btn) { btn.disabled = true; btn.textContent = '…'; } + showToast && showToast('Searching stream for comparison…', 'info'); + try { + const res = await fetch(`/api/verification/${hid}/compare-stream`, { method: 'POST' }); + const data = await res.json(); + if (data.success && data.result && typeof startStream === 'function') { + await startStream(data.result); + } else { + showToast && showToast(data.error || 'No stream candidate found for comparison', 'error'); + } + } catch (e) { + showToast && showToast('Stream failed: ' + e.message, 'error'); + } + if (btn) { btn.disabled = false; btn.textContent = '⇆'; } +} + +async function verifAudit(hid) { + try { + const r = await fetch(`/api/verification/${hid}/entry`); + const d = await r.json(); + if (d.success && d.entry && typeof openDownloadAuditModal === 'function') { + openDownloadAuditModal(d.entry); + } else { + showToast && showToast(d.error || 'Audit data not available', 'error'); + } + } catch (e) { + showToast && showToast('Audit load failed', 'error'); } - 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) { @@ -2427,9 +2488,326 @@ async function verifDelete(hid, btn) { } catch (e) { showToast && showToast('Delete failed', 'error'); } if (btn) btn.disabled = false; } -window.verifTogglePlay = verifTogglePlay; +// ---- Quarantine sub-view inside the ⚠ filter ---- +// The review queue covers BOTH kinds of suspect files: imported-but- +// unconfirmed (unverified/force_imported) and not-imported-at-all +// (quarantined). One place to listen, compare, approve or delete. +let _verifSubView = 'unverified'; +let _verifQuarEntries = []; +let _verifQuarLoaded = false; +let _verifQuarLoading = false; +// Expanded 🔍 detail panels, keyed by quarantine entry id — survives the +// polling re-render (which rebuilds the rows every few seconds). +const _verifQuarOpenDetails = new Set(); +// null = not fetched yet (assume enabled). Without an AcoustID API key +// nothing ever gets a verification status, so the review queue collapses +// to quarantine-only. +let _verifAcoustidEnabled = null; +let _verifConfigLoading = false; + +async function _verifLoadConfig() { + if (_verifAcoustidEnabled !== null || _verifConfigLoading) return; + _verifConfigLoading = true; + try { + const r = await fetch('/api/verification/config'); + const d = await r.json(); + _verifAcoustidEnabled = !!(d && d.acoustid_enabled); + } catch (e) { _verifAcoustidEnabled = true; } + _verifConfigLoading = false; + if (_verifAcoustidEnabled === false) { + _verifSubView = 'quarantine'; + const pill = document.querySelector('.adl-pill[data-filter="unverified"]'); + if (pill) { + pill.textContent = '🛡 Quarantine'; + pill.title = 'Files that failed import checks and were NOT imported. (AcoustID is not configured, so there is no unverified review queue.)'; + } + if (_adlFilter === 'unverified') { _verifLoadQuarantine(true); _adlRender(); } + } +} + +function verifSetSubView(v) { + if (_verifAcoustidEnabled === false) v = 'quarantine'; + _verifSubView = v === 'quarantine' ? 'quarantine' : 'unverified'; + if (_verifSubView === 'quarantine') _verifLoadQuarantine(true); + _adlRender(); +} + +async function _verifLoadQuarantine(force) { + if (_verifQuarLoading || (_verifQuarLoaded && !force)) return; + _verifQuarLoading = true; + try { + const r = await fetch('/api/quarantine/list'); + const d = await r.json(); + _verifQuarEntries = (d.success && Array.isArray(d.entries)) ? d.entries : []; + } catch (e) { _verifQuarEntries = []; } + _verifQuarLoaded = true; + _verifQuarLoading = false; + if (_adlFilter === 'unverified') _adlRender(); +} + +const _VERIF_QUAR_TRIGGERS = { + integrity: ['DURATION / INTEGRITY', 'verif-rb-int'], + acoustid: ['ACOUSTID MISMATCH', 'verif-rb-force'], + bit_depth: ['BIT DEPTH FILTER', 'verif-rb-int'], +}; + +function _verifQuarRows() { + if (!_verifQuarLoaded) return '
Loading quarantine…
'; + if (!_verifQuarEntries.length) return ''; + let html = ''; + _verifQuarEntries.forEach((q, idx) => { + 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 [trigLabel, trigClass] = _VERIF_QUAR_TRIGGERS[q.trigger] || ['QUARANTINED', 'verif-rb-unv']; + const timeAgo = _verifTimeAgo(q.timestamp); + const approveBtn = q.has_full_context + ? `` + : ``; + const details = [ + q.reason ? `
Reason: ${_adlEsc(q.reason)}
` : '', + q.source_username ? `
Source uploader: ${_adlEsc(q.source_username)}
` : '', + q.source_filename ? `
Original Soulseek file: ${_adlEsc(q.source_filename)}
` : '', + q.timestamp ? `
Quarantined: ${_adlEsc(q.timestamp)}
` : '', + ].filter(Boolean).join(''); + const detailsOpen = _verifQuarOpenDetails.has(q.id); + const artHtml = q.thumb_url + ? `` + : '
'; + html += `
+ ${artHtml} +
+
${title}
+ ${meta ? `
${meta}
` : ''} +
${details || 'No further details in the sidecar.'}
+
+
+ ${trigLabel} + ${timeAgo ? `${timeAgo}` : ''} + + + + ${approveBtn} + +
+
`; + }); + return html; +} + +function verifQuarInspect(idx) { + // Open-state lives in a Set keyed by entry id (not the DOM) — the polling + // re-render rebuilds the rows every few seconds and would collapse a + // DOM-only toggle right after the click. + const q = _verifQuarEntries[idx]; + if (!q) return; + if (_verifQuarOpenDetails.has(q.id)) _verifQuarOpenDetails.delete(q.id); + else _verifQuarOpenDetails.add(q.id); + const el = document.getElementById(`verif-quar-details-${idx}`); + if (el) el.style.display = _verifQuarOpenDetails.has(q.id) ? '' : 'none'; +} + +async function verifQuarAudit(idx) { + // Same Audit Trail modal as the unverified rows — the backend synthesizes + // a history-shaped entry from the quarantine sidecar (these files were + // never imported, so no real history row exists). + const q = _verifQuarEntries[idx]; if (!q) return; + try { + const r = await fetch(`/api/quarantine/${encodeURIComponent(q.id)}/entry`); + const d = await r.json(); + if (d.success && d.entry && typeof openDownloadAuditModal === 'function') { + openDownloadAuditModal(d.entry); + } else { + showToast && showToast(d.error || 'Audit data not available', 'error'); + } + } catch (e) { + showToast && showToast('Audit load failed', 'error'); + } +} + +async function verifQuarPlay(idx) { + const q = _verifQuarEntries[idx]; if (!q) return; + try { + if (typeof setTrackInfo === 'function') { + setTrackInfo({ + title: `${q.expected_track || q.original_filename || 'Quarantined file'} (quarantined)`, + artist: q.expected_artist || '', + album: '', + is_library: true, + }); + } + if (typeof showLoadingAnimation === 'function') showLoadingAnimation(); + const r = await fetch(`/api/quarantine/${encodeURIComponent(q.id)}/play`, { method: 'POST' }); + const d = await r.json(); + if (!d.success) throw new Error(d.error || 'Playback failed'); + await startAudioPlayback(); + } catch (e) { + if (typeof hideLoadingAnimation === 'function') hideLoadingAnimation(); + showToast && showToast('Playback failed: ' + e.message, 'error'); + } +} + +async function verifQuarCompare(idx, btn) { + const q = _verifQuarEntries[idx]; if (!q) return; + if (btn) { btn.disabled = true; btn.textContent = '…'; } + showToast && showToast(`Searching stream for "${q.expected_track || ''}"…`, 'info'); + try { + const res = await fetch(`/api/quarantine/${encodeURIComponent(q.id)}/compare-stream`, { method: 'POST' }); + const data = await res.json(); + if (data.success && data.result && typeof startStream === 'function') { + await startStream(data.result); + } else { + showToast && showToast(data.error || 'No stream candidate found for comparison', 'error'); + } + } catch (e) { + showToast && showToast('Stream failed: ' + e.message, 'error'); + } + if (btn) { btn.disabled = false; btn.textContent = '⇆'; } +} + +async function verifQuarApprove(idx, btn) { + const q = _verifQuarEntries[idx]; if (!q) return; + if (btn) btn.disabled = true; + try { + const r = await fetch(`/api/quarantine/${encodeURIComponent(q.id)}/approve`, { method: 'POST' }); + const d = await r.json(); + if (d.success) { + showToast && showToast('Approved — re-importing, will be marked human-verified 🛡✔', 'success'); + _verifLoadQuarantine(true); + } else { + showToast && showToast(d.error || 'Approve failed', 'error'); + } + } catch (e) { showToast && showToast('Approve failed', 'error'); } + if (btn) btn.disabled = false; +} + +async function verifQuarRecover(idx, btn) { + const q = _verifQuarEntries[idx]; if (!q) return; + if (btn) btn.disabled = true; + try { + const r = await fetch(`/api/quarantine/${encodeURIComponent(q.id)}/recover`, { method: 'POST' }); + const d = await r.json(); + if (d.success) { + showToast && showToast('Moved to Staging — finish via the Import page', 'success'); + _verifLoadQuarantine(true); + } else { + showToast && showToast(d.error || 'Recover failed', 'error'); + } + } catch (e) { showToast && showToast('Recover failed', 'error'); } + if (btn) btn.disabled = false; +} + +async function verifQuarDelete(idx, btn) { + const q = _verifQuarEntries[idx]; if (!q) return; + if (!confirm('Permanently delete this quarantined file?')) return; + if (btn) btn.disabled = true; + try { + const r = await fetch(`/api/quarantine/${encodeURIComponent(q.id)}`, { method: 'DELETE' }); + const d = await r.json(); + if (d.success) { showToast && showToast('Quarantined file deleted', 'success'); _verifLoadQuarantine(true); } + else showToast && showToast(d.error || 'Delete failed', 'error'); + } catch (e) { showToast && showToast('Delete failed', 'error'); } + if (btn) btn.disabled = false; +} + +function _verifUnverifiedIds() { + const done = ['completed', 'skipped', 'already_owned']; + return _adlData + .filter(d => done.includes(d.status) && + (d.verification_status === 'unverified' || d.verification_status === 'force_imported')) + .map(d => verifHistoryId(d)) + .filter(Boolean); +} + +async function verifApproveAll(btn) { + const ids = _verifUnverifiedIds(); + if (!ids.length) return; + if (!confirm(`Mark all ${ids.length} unverified entries as human-verified? The AcoustID scanner will skip them from now on.`)) return; + if (btn) btn.disabled = true; + let ok = 0; + for (const id of ids) { + try { + const r = await fetch(`/api/verification/${id}/approve`, { method: 'POST' }); + const d = await r.json(); + if (d.success) ok++; + } catch (e) {} + } + showToast && showToast(`Approved ${ok}/${ids.length} entries 🛡✔`, ok === ids.length ? 'success' : 'error'); + if (btn) btn.disabled = false; + _adlFetch(); +} + +async function verifDeleteAll(btn) { + const ids = _verifUnverifiedIds(); + if (!ids.length) return; + if (!confirm(`Delete ALL ${ids.length} unverified files from disk and remove their entries? This cannot be undone.`)) return; + if (btn) btn.disabled = true; + let ok = 0; + for (const id of ids) { + try { + const r = await fetch(`/api/verification/${id}/delete`, { method: 'POST' }); + const d = await r.json(); + if (d.success) ok++; + } catch (e) {} + } + showToast && showToast(`Deleted ${ok}/${ids.length} files`, ok === ids.length ? 'success' : 'error'); + if (btn) btn.disabled = false; + _adlFetch(); +} + +async function verifQuarApproveAll(btn) { + const entries = _verifQuarEntries.filter(q => q.has_full_context); + if (!entries.length) { + showToast && showToast('No one-click-approvable entries (legacy sidecars need Recover)', 'info'); + return; + } + if (!confirm(`Approve and re-import all ${entries.length} quarantined files? They will be imported into the library marked human-verified.`)) return; + if (btn) btn.disabled = true; + let ok = 0; + for (const q of entries) { + try { + const r = await fetch(`/api/quarantine/${encodeURIComponent(q.id)}/approve`, { method: 'POST' }); + const d = await r.json(); + if (d.success) ok++; + } catch (e) {} + // Each approve spawns a server-side re-import thread — stagger them. + await new Promise(res => setTimeout(res, 500)); + } + showToast && showToast(`Approved ${ok}/${entries.length} — re-importing as human-verified`, ok === entries.length ? 'success' : 'error'); + if (btn) btn.disabled = false; + _verifLoadQuarantine(true); +} + +async function verifQuarClearAll(btn) { + if (!_verifQuarEntries.length) return; + if (!confirm(`Permanently delete ALL ${_verifQuarEntries.length} quarantined files? This cannot be undone.`)) return; + if (btn) btn.disabled = true; + try { + const r = await fetch('/api/quarantine/clear', { method: 'POST' }); + const d = await r.json(); + if (d.success) showToast && showToast('Quarantine cleared', 'success'); + else showToast && showToast(d.error || 'Clear failed', 'error'); + } catch (e) { showToast && showToast('Clear failed', 'error'); } + if (btn) btn.disabled = false; + _verifLoadQuarantine(true); +} + +window.verifPlay = verifPlay; window.verifApprove = verifApprove; window.verifDelete = verifDelete; +window.verifCompare = verifCompare; +window.verifAudit = verifAudit; +window.verifSetSubView = verifSetSubView; +window.verifApproveAll = verifApproveAll; +window.verifDeleteAll = verifDeleteAll; +window.verifQuarPlay = verifQuarPlay; +window.verifQuarCompare = verifQuarCompare; +window.verifQuarInspect = verifQuarInspect; +window.verifQuarAudit = verifQuarAudit; +window.verifQuarApprove = verifQuarApprove; +window.verifQuarRecover = verifQuarRecover; +window.verifQuarDelete = verifQuarDelete; +window.verifQuarApproveAll = verifQuarApproveAll; +window.verifQuarClearAll = verifQuarClearAll; function _adlRender() { const list = document.getElementById('adl-list'); @@ -2506,6 +2884,48 @@ function _adlRender() { existingBanner.style.display = 'none'; } + // Review queue sub-view toggle: unverified imports ⇄ quarantined files. + let verifBanner = document.getElementById('verif-subview-banner'); + if (_adlFilter === 'unverified') { + _verifLoadConfig(); // no-op once fetched + if (!verifBanner) { + verifBanner = document.createElement('div'); + verifBanner.id = 'verif-subview-banner'; + verifBanner.className = 'adl-batch-filter-banner'; + list.parentNode.insertBefore(verifBanner, list); + } + const quarCount = _verifQuarLoaded ? ` (${_verifQuarEntries.length})` : ''; + const bulkBtns = _verifSubView === 'quarantine' + ? ` + ` + : ` + `; + // Without an AcoustID key nothing ever gets a verification status — + // hide the pointless Unverified pill and show quarantine only. + const unvPill = _verifAcoustidEnabled === false + ? '' + : ``; + verifBanner.innerHTML = ` + ${unvPill} + + + ${bulkBtns}`; + verifBanner.style.display = ''; + if (!_verifQuarLoaded) _verifLoadQuarantine(false); // count for the pill + } else if (verifBanner) { + verifBanner.style.display = 'none'; + } + + if (_adlFilter === 'unverified' && _verifSubView === 'quarantine') { + const qhtml = _verifQuarRows(); + const qEmptyEl = document.getElementById('adl-empty'); + const qEmptyHtml = qEmptyEl ? qEmptyEl.outerHTML : ''; + list.innerHTML = qEmptyHtml + qhtml; + const qNewEmpty = document.getElementById('adl-empty'); + if (qNewEmpty) qNewEmpty.style.display = qhtml ? 'none' : ''; + return; + } + if (filtered.length === 0) { if (empty) empty.style.display = ''; // Clear any existing rows but keep the empty message