fix: eager config load + check acoustid.enabled for verification pill
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 <noreply@anthropic.com>
This commit is contained in:
parent
97b40cbd43
commit
5896f2dcc6
2 changed files with 782 additions and 39 deletions
361
web_server.py
361
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/<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):
|
||||
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/<int:history_id>/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/<int:history_id>/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/<int:history_id>/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/<entry_id>/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/<entry_id>/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_id>/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/<entry_id>/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/<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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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-<dbid>'.
|
||||
|
|
@ -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 '<span class="verif-reason-badge verif-rb-force" title="Accepted as best candidate after the retry budget was exhausted (version-mismatch fallback)">FORCE-IMPORTED</span>';
|
||||
}
|
||||
if (dl.verification_status === 'unverified') {
|
||||
return '<span class="verif-reason-badge verif-rb-unv" title="AcoustID could not hard-confirm this file (ambiguous / cross-script / no fingerprint match)">ACOUSTID UNCONFIRMED</span>';
|
||||
}
|
||||
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 `<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>
|
||||
${_verifReasonBadge(dl)}
|
||||
${timeAgo ? `<span class="verif-time">${timeAgo}</span>` : ''}
|
||||
<button class="verif-act verif-act-play" onclick="verifPlay('${hid}')" title="Play the downloaded file in the media player">▶</button>
|
||||
<button class="verif-act" onclick="verifCompare('${hid}', this)" title="Find this track on Soulseek/streaming sources and play it in the media player — compare against your file">⇆</button>
|
||||
<button class="verif-act" onclick="verifAudit('${hid}')" title="Open the audit trail for this download (lifecycle, embedded tags, lyrics)">🔍</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;
|
||||
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 '<div class="adl-section-header">Loading quarantine…</div>';
|
||||
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
|
||||
? `<button class="verif-act verif-act-ok" onclick="verifQuarApprove(${idx}, this)" title="Approve: re-import this exact file into the library, marked human-verified">✔</button>`
|
||||
: `<button class="verif-act verif-act-ok" onclick="verifQuarRecover(${idx}, this)" title="Recover to Staging for a manual import (legacy entry without embedded context)">⤴</button>`;
|
||||
const details = [
|
||||
q.reason ? `<div><span class="verif-detail-label">Reason:</span> ${_adlEsc(q.reason)}</div>` : '',
|
||||
q.source_username ? `<div><span class="verif-detail-label">Source uploader:</span> ${_adlEsc(q.source_username)}</div>` : '',
|
||||
q.source_filename ? `<div><span class="verif-detail-label">Original Soulseek file:</span> ${_adlEsc(q.source_filename)}</div>` : '',
|
||||
q.timestamp ? `<div><span class="verif-detail-label">Quarantined:</span> ${_adlEsc(q.timestamp)}</div>` : '',
|
||||
].filter(Boolean).join('');
|
||||
const detailsOpen = _verifQuarOpenDetails.has(q.id);
|
||||
const artHtml = q.thumb_url
|
||||
? `<img class="adl-row-art" src="${_adlEsc(q.thumb_url)}" alt="" onerror="this.style.display='none'">`
|
||||
: '<div class="adl-row-art adl-row-art-empty"></div>';
|
||||
html += `<div class="adl-row adl-row-failed verif-quar-row" data-quarantine-id="${_adlEsc(q.id)}" onclick="verifQuarInspect(${idx})" title="Click to show/hide details (reason, source uploader, original filename)">
|
||||
${artHtml}
|
||||
<div class="adl-row-info">
|
||||
<div class="adl-row-title">${title}</div>
|
||||
${meta ? `<div class="adl-row-meta">${meta}</div>` : ''}
|
||||
<div class="verif-quar-details" id="verif-quar-details-${idx}" style="display:${detailsOpen ? '' : 'none'}">${details || 'No further details in the sidecar.'}</div>
|
||||
</div>
|
||||
<div class="verif-actions" onclick="event.stopPropagation()">
|
||||
<span class="verif-reason-badge ${trigClass}" title="${_adlEsc(q.reason || '')}">${trigLabel}</span>
|
||||
${timeAgo ? `<span class="verif-time">${timeAgo}</span>` : ''}
|
||||
<button class="verif-act verif-act-play" onclick="verifQuarPlay(${idx})" title="Play the quarantined file in the media player">▶</button>
|
||||
<button class="verif-act" onclick="verifQuarCompare(${idx}, this)" title="Find the expected track on Soulseek/streaming sources and play it in the media player — compare against the quarantined file">⇆</button>
|
||||
<button class="verif-act" onclick="verifQuarAudit(${idx})" title="Open the audit trail for this quarantined file (details, embedded tags, lyrics)">🔍</button>
|
||||
${approveBtn}
|
||||
<button class="verif-act verif-act-del" onclick="verifQuarDelete(${idx}, this)" title="Delete the quarantined file permanently">🗑</button>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
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'
|
||||
? `<button class="adl-filter-banner-clear" onclick="verifQuarApproveAll(this)" title="Approve + re-import every quarantined file (marked human-verified)">✔ Approve all</button>
|
||||
<button class="adl-filter-banner-clear verif-bulk-danger" onclick="verifQuarClearAll(this)" title="Permanently delete every quarantined file">🗑 Clear all</button>`
|
||||
: `<button class="adl-filter-banner-clear" onclick="verifApproveAll(this)" title="Mark every listed entry as human-verified">✔ Approve all</button>
|
||||
<button class="adl-filter-banner-clear verif-bulk-danger" onclick="verifDeleteAll(this)" title="Delete every listed file from disk and remove its entry">🗑 Delete all</button>`;
|
||||
// Without an AcoustID key nothing ever gets a verification status —
|
||||
// hide the pointless Unverified pill and show quarantine only.
|
||||
const unvPill = _verifAcoustidEnabled === false
|
||||
? ''
|
||||
: `<button class="adl-pill${_verifSubView === 'unverified' ? ' active' : ''}" onclick="verifSetSubView('unverified')" title="Imported files that AcoustID could not hard-confirm">⚠ Unverified (${filtered.length})</button>`;
|
||||
verifBanner.innerHTML = `
|
||||
${unvPill}
|
||||
<button class="adl-pill${_verifSubView === 'quarantine' ? ' active' : ''}" onclick="verifSetSubView('quarantine')" title="Files that failed verification and were NOT imported">🛡 Quarantine${quarCount}</button>
|
||||
<span class="verif-banner-spacer"></span>
|
||||
${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
|
||||
|
|
|
|||
Loading…
Reference in a new issue