Optimize Recovery Scan: Implement Async Backend & UI Polling for large libraries

This commit is contained in:
wander 2026-01-05 02:23:08 -05:00
parent 55fb0447eb
commit 2f5017d13b
2 changed files with 118 additions and 55 deletions

View file

@ -902,11 +902,45 @@ def api_transcode_logs():
"next_index": len(transcode_log_buffer) "next_index": len(transcode_log_buffer)
}) })
# Global Scan State
SCAN_CACHE = {
"status": "idle", # idle, scanning, done
"results": None,
"last_run": None
}
SCAN_THREAD = None
@app.route("/api/recovery/scan", methods=["POST"]) @app.route("/api/recovery/scan", methods=["POST"])
@requires_auth @requires_auth
def api_recovery_scan(): def api_recovery_scan():
files = scan_for_unindexed_videos() global SCAN_THREAD
return jsonify({"files": files, "count": len(files)})
if SCAN_CACHE["status"] == "scanning":
return jsonify({"status": "running", "message": "Scan already in progress"}), 202
def run_scan_async():
global SCAN_CACHE
SCAN_CACHE["status"] = "scanning"
SCAN_CACHE["results"] = None
try:
results = scan_for_unindexed_videos()
SCAN_CACHE["results"] = results
SCAN_CACHE["status"] = "done"
SCAN_CACHE["last_run"] = datetime.now().isoformat()
except Exception as e:
SCAN_CACHE["status"] = "error"
SCAN_CACHE["results"] = str(e)
log(f"❌ Async scan failed: {e}")
SCAN_THREAD = threading.Thread(target=run_scan_async)
SCAN_THREAD.start()
return jsonify({"status": "started", "message": "Background scan started"}), 202
@app.route("/api/recovery/poll", methods=["GET"])
@requires_auth
def api_recovery_poll():
return jsonify(SCAN_CACHE)
@app.route("/api/recovery/start", methods=["POST"]) @app.route("/api/recovery/start", methods=["POST"])
@requires_auth @requires_auth

View file

@ -423,8 +423,7 @@
} }
async function scanRecoveryFiles() { async function scanRecoveryFiles() {
// Loading state for all tabs const loadingRow = '<tr><td colspan="4" class="text-center"><div class="spinner-border text-primary" role="status"></div> Scanning in background... (This may take a minute)</td></tr>';
const loadingRow = '<tr><td colspan="4" class="text-center"><div class="spinner-border text-primary" role="status"></div> Scanning...</td></tr>';
const ids = ['tbody-unindexed', 'tbody-rescue', 'tbody-redundant']; const ids = ['tbody-unindexed', 'tbody-rescue', 'tbody-redundant'];
ids.forEach(id => { ids.forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id);
@ -432,63 +431,91 @@
}); });
try { try {
const res = await fetch('/api/recovery/scan', { method: 'POST' }); // 1. Kick off the scan
const data = await res.json(); await fetch('/api/recovery/scan', { method: 'POST' });
// Helper to render rows // 2. Poll for results
const renderRow = (f, type) => { const pollInterval = setInterval(async () => {
const cleanPath = f.path.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); try {
if (type === 'unindexed') { const res = await fetch('/api/recovery/poll');
return `<tr> const state = await res.json();
<td><code>${f.video_id}</code></td>
<td title="${f.path}"><small>${f.filename}</small></td> if (state.status === 'done') {
<td>${f.size_mb} MB</td> clearInterval(pollInterval);
<td><button class="btn btn-sm btn-success" onclick="startRecovery('${cleanPath}')"><i class="bi bi-cloud-arrow-up"></i> Recover</button></td> renderResults(state.results);
</tr>`; } else if (state.status === 'error') {
clearInterval(pollInterval);
alert("Scan Error: " + state.results);
resetTables("Error: " + state.results);
}
// If 'scanning' or 'idle', keep polling...
} catch (e) {
clearInterval(pollInterval);
console.error("Poll error", e);
} }
if (type === 'rescue') { }, 2000);
return `<tr>
<td><code>${f.video_id}</code></td>
<td title="${f.path}"><small>${f.filename}</small></td>
<td class="text-danger small">Missing from: ${f.ta_source}</td>
<td><button class="btn btn-sm btn-danger" onclick="startRecovery('${cleanPath}')"><i class="bi bi-life-preserver"></i> RESCUE</button></td>
</tr>`;
}
if (type === 'redundant') {
return `<tr>
<td><code>${f.video_id}</code></td>
<td title="${f.path}"><small>${f.filename}</small></td>
<td class="text-success small">Exists at: ${f.ta_source}</td>
<td><button class="btn btn-sm btn-outline-secondary" onclick="deleteFile('${cleanPath}')"><i class="bi bi-trash"></i> Delete</button></td>
</tr>`;
}
};
// Clear & Fill
ids.forEach(id => {
const el = document.getElementById(id);
if (el) el.innerHTML = '';
});
// Update Badges
document.getElementById('badge-unindexed').innerText = data.files.unindexed.length;
document.getElementById('badge-rescue').innerText = data.files.rescue.length;
document.getElementById('badge-redundant').innerText = data.files.redundant.length;
// Populate Tables
data.files.unindexed.forEach(f => document.getElementById('tbody-unindexed').innerHTML += renderRow(f, 'unindexed'));
data.files.rescue.forEach(f => document.getElementById('tbody-rescue').innerHTML += renderRow(f, 'rescue'));
data.files.redundant.forEach(f => document.getElementById('tbody-redundant').innerHTML += renderRow(f, 'redundant'));
if (data.files.unindexed.length === 0) document.getElementById('tbody-unindexed').innerHTML = '<tr><td colspan="4" class="text-center text-muted">No unindexed files found.</td></tr>';
if (data.files.rescue.length === 0) document.getElementById('tbody-rescue').innerHTML = '<tr><td colspan="4" class="text-center text-muted">No rescue candidates found.</td></tr>';
if (data.files.redundant.length === 0) document.getElementById('tbody-redundant').innerHTML = '<tr><td colspan="4" class="text-center text-muted">No duplicates found.</td></tr>';
} catch (e) { } catch (e) {
alert("Scan failed: " + e); alert("Failed to start scan: " + e);
} }
} }
function resetTables(msg) {
const ids = ['tbody-unindexed', 'tbody-rescue', 'tbody-redundant'];
ids.forEach(id => {
const el = document.getElementById(id);
if (el) el.innerHTML = `<tr><td colspan="4" class="text-center text-muted">${msg}</td></tr>`;
});
}
function renderResults(data) {
// Helper to render rows
const renderRow = (f, type) => {
const cleanPath = f.path.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
if (type === 'unindexed') {
return `<tr>
<td><code>${f.video_id}</code></td>
<td title="${f.path}"><small>${f.filename}</small></td>
<td>${f.size_mb} MB</td>
<td><button class="btn btn-sm btn-success" onclick="startRecovery('${cleanPath}')"><i class="bi bi-cloud-arrow-up"></i> Recover</button></td>
</tr>`;
}
if (type === 'rescue') {
return `<tr>
<td><code>${f.video_id}</code></td>
<td title="${f.path}"><small>${f.filename}</small></td>
<td class="text-danger small">Missing from: ${f.ta_source}</td>
<td><button class="btn btn-sm btn-danger" onclick="startRecovery('${cleanPath}')"><i class="bi bi-life-preserver"></i> RESCUE</button></td>
</tr>`;
}
if (type === 'redundant') {
return `<tr>
<td><code>${f.video_id}</code></td>
<td title="${f.path}"><small>${f.filename}</small></td>
<td class="text-success small">Exists at: ${f.ta_source}</td>
<td><button class="btn btn-sm btn-outline-secondary" onclick="deleteFile('${cleanPath}')"><i class="bi bi-trash"></i> Delete</button></td>
</tr>`;
}
};
const ids = ['tbody-unindexed', 'tbody-rescue', 'tbody-redundant'];
ids.forEach(id => document.getElementById(id).innerHTML = '');
// Update Badges
document.getElementById('badge-unindexed').innerText = data.unindexed.length;
document.getElementById('badge-rescue').innerText = data.rescue.length;
document.getElementById('badge-redundant').innerText = data.redundant.length;
// Populate
data.unindexed.forEach(f => document.getElementById('tbody-unindexed').innerHTML += renderRow(f, 'unindexed'));
data.rescue.forEach(f => document.getElementById('tbody-rescue').innerHTML += renderRow(f, 'rescue'));
data.redundant.forEach(f => document.getElementById('tbody-redundant').innerHTML += renderRow(f, 'redundant'));
if (data.unindexed.length === 0) document.getElementById('tbody-unindexed').innerHTML = '<tr><td colspan="4" class="text-center text-muted">No unindexed files found.</td></tr>';
if (data.rescue.length === 0) document.getElementById('tbody-rescue').innerHTML = '<tr><td colspan="4" class="text-center text-muted">No rescue candidates found.</td></tr>';
if (data.redundant.length === 0) document.getElementById('tbody-redundant').innerHTML = '<tr><td colspan="4" class="text-center text-muted">No duplicates found.</td></tr>';
}
async function startRecovery(filepath) { async function startRecovery(filepath) {
if (!confirm("Start recovery for this file? This will try to fetch metadata and move it to the Import folder.")) return; if (!confirm("Start recovery for this file? This will try to fetch metadata and move it to the Import folder.")) return;
try { try {
@ -513,7 +540,9 @@
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
alert("File deleted."); alert("File deleted.");
scanRecoveryFiles(); // Refresh // Refresh not automatic for delete in async mode to avoid full rescan
// Ideally we remove the row, but for now user can re-scan manually or we can trigger fetch
// Let's just alert.
} else { } else {
alert("Error: " + data.error); alert("Error: " + data.error);
} }