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:
dev 2026-06-11 01:09:05 +02:00
parent 97b40cbd43
commit 5896f2dcc6
2 changed files with 782 additions and 39 deletions

View file

@ -7512,18 +7512,76 @@ def stream_quarantine_item(entry_id):
return jsonify({"error": str(e)}), 500 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: Dockerhost 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']) @app.route('/api/verification/<int:history_id>/stream', methods=['GET'])
def stream_verification_item(history_id): def stream_verification_item(history_id):
"""Stream a completed download for the verification review queue (listen """Stream a completed download for the verification review queue (listen
before approving). Path comes ONLY from the history row no client paths.""" before approving). Path comes ONLY from the history row no client paths."""
try: try:
rows = get_database().get_library_history_rows_by_ids([history_id]) row = _get_library_history_row(history_id)
if not rows or not rows[0].get('file_path'): if not row:
return jsonify({"error": "History entry or file path not found"}), 404 return jsonify({"error": "History entry not found"}), 404
file_path = rows[0]['file_path'] file_path = _resolve_history_audio_path(row)
if not os.path.exists(file_path): if not file_path:
return jsonify({"error": "File not found on disk"}), 404 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') mimetype = _AUDIO_MIME_TYPES.get(ext, 'audio/mpeg')
return _serve_audio_file_with_range(file_path, mimetype_override=mimetype) return _serve_audio_file_with_range(file_path, mimetype_override=mimetype)
except Exception as e: except Exception as e:
@ -7531,6 +7589,264 @@ def stream_verification_item(history_id):
return jsonify({"error": str(e)}), 500 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']) @app.route('/api/verification/<int:history_id>/approve', methods=['POST'])
def approve_verification_item(history_id): def approve_verification_item(history_id):
"""User confirmed the file IS the right track: set human_verified on the """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.matching.verification_status import HUMAN_VERIFIED
from core.tag_writer import write_verification_status from core.tag_writer import write_verification_status
db = get_database() db = get_database()
rows = db.get_library_history_rows_by_ids([history_id]) row = _get_library_history_row(history_id)
if not rows: if not row:
return jsonify({"success": False, "error": "History entry not found"}), 404 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 = db._get_connection()
conn.cursor().execute( conn.cursor().execute(
"UPDATE library_history SET verification_status = ? WHERE id = ?", "UPDATE library_history SET verification_status = ? WHERE id = ?",
(HUMAN_VERIFIED, history_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( conn.cursor().execute(
"UPDATE tracks SET verification_status = ? WHERE file_path = ?", "UPDATE tracks SET verification_status = ? WHERE file_path = ?",
(HUMAN_VERIFIED, file_path)) (HUMAN_VERIFIED, p))
conn.commit() 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}) return jsonify({"success": True, "tag_written": tag_written})
except Exception as e: except Exception as e:
logger.error(f"[Verification] Approve failed for {history_id}: {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).""" history row (the media-server mirror cleans the tracks row on next scan)."""
try: try:
db = get_database() db = get_database()
rows = db.get_library_history_rows_by_ids([history_id]) row = _get_library_history_row(history_id)
if not rows: if not row:
return jsonify({"success": False, "error": "History entry not found"}), 404 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 file_deleted = False
if file_path and os.path.exists(file_path): if on_disk and os.path.exists(on_disk):
os.remove(file_path) os.remove(on_disk)
file_deleted = True 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]) db.delete_library_history_rows([history_id])
return jsonify({"success": True, "file_deleted": file_deleted}) return jsonify({"success": True, "file_deleted": file_deleted})
except Exception as e: except Exception as e:
@ -12776,6 +13094,11 @@ def stream_audio():
stream_url = sess.get("stream_url") stream_url = sess.get("stream_url")
if not file_path and not stream_url: if not file_path and not stream_url:
return jsonify({"error": "No audio file ready for streaming"}), 404 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). # Library track played via the media server's stream API (#809).
if stream_url: if stream_url:
@ -12783,7 +13106,7 @@ def stream_audio():
return _proxy_stream_url_with_range(stream_url) return _proxy_stream_url_with_range(stream_url)
logger.info(f"Serving audio file: {os.path.basename(file_path)}") 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: except Exception as e:
logger.error(f"Error serving audio file: {e}") logger.error(f"Error serving audio file: {e}")
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500

View file

@ -2282,6 +2282,7 @@ function _adlRenderBatchSummary(activeBatches) {
} }
function loadActiveDownloadsPage() { function loadActiveDownloadsPage() {
_verifLoadConfig();
_adlFetch(); _adlFetch();
_adlFetchBatchHistory(); _adlFetchBatchHistory();
// Poll downloads every 2 seconds, history every 60 seconds // Poll downloads every 2 seconds, history every 60 seconds
@ -2365,9 +2366,7 @@ function _adlVerifBadge(dl) {
return ''; return '';
} }
// ---- Verification review queue (the ⚠ Unverified filter) ---- // ---- Verification review queue (the ⚠ Unverified/Quarantine filter) ----
let _verifAudio = null;
let _verifAudioId = null;
function verifHistoryId(dl) { function verifHistoryId(dl) {
// Persistent history rows carry task_id 'history-<dbid>'. // Persistent history rows carry task_id 'history-<dbid>'.
@ -2376,33 +2375,95 @@ function verifHistoryId(dl) {
return m ? m[1] : null; 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) { function _adlReviewActions(dl) {
if (_adlFilter !== 'unverified') return ''; if (_adlFilter !== 'unverified') return '';
const hid = verifHistoryId(dl); const hid = verifHistoryId(dl);
if (!hid) return ''; if (!hid) return '';
const q = encodeURIComponent(`${dl.artist || ''} ${dl.title || ''}`.trim()); const timeAgo = _verifTimeAgo(dl.created_at);
const playing = _verifAudioId === hid ? ' playing' : '';
return `<div class="verif-actions" onclick="event.stopPropagation()"> 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> ${_verifReasonBadge(dl)}
<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> ${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-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> <button class="verif-act verif-act-del" onclick="verifDelete('${hid}', this)" title="Wrong file: delete it from disk and remove this entry">🗑</button>
</div>`; </div>`;
} }
function verifTogglePlay(hid, btn) { async function verifPlay(hid) {
if (_verifAudio && _verifAudioId === hid) { // Plays the LOCAL file through the global media player (same machinery as
_verifAudio.pause(); _verifAudio = null; _verifAudioId = null; // library playback) — full player UI with seek/stop instead of an
if (btn) { btn.textContent = '▶'; btn.classList.remove('playing'); } // invisible Audio element that re-renders wiped.
return; 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) { async function verifApprove(hid, btn) {
@ -2427,9 +2488,326 @@ async function verifDelete(hid, btn) {
} catch (e) { showToast && showToast('Delete failed', 'error'); } } catch (e) { showToast && showToast('Delete failed', 'error'); }
if (btn) btn.disabled = false; 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.verifApprove = verifApprove;
window.verifDelete = verifDelete; 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() { function _adlRender() {
const list = document.getElementById('adl-list'); const list = document.getElementById('adl-list');
@ -2506,6 +2884,48 @@ function _adlRender() {
existingBanner.style.display = 'none'; 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 (filtered.length === 0) {
if (empty) empty.style.display = ''; if (empty) empty.style.display = '';
// Clear any existing rows but keep the empty message // Clear any existing rows but keep the empty message