feat(dashcam): async import with polling status

- Import now returns immediately with {status: 'started'}
- Backend runs copy in a daemon thread, tracks job state in _import_job
- New GET /api/dash/import/status endpoint for polling
- UI polls every 3s, shows progress bar until done
- Page reload mid-import resumes polling automatically
- Prevents double-import with 'busy' guard

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-10 01:12:00 -04:00
co-authored by Copilot
parent 7d124b0fa0
commit 71f8ce2993
3 changed files with 115 additions and 63 deletions
+12 -15
View File
@@ -97,21 +97,18 @@ def dash_devices():
@app.route('/api/dash/import', methods=['POST']) @app.route('/api/dash/import', methods=['POST'])
def dash_import(): def dash_import():
body = request.get_json(silent=True) body = request.get_json(silent=True)
if body is None: relative_paths = body.get("paths") if isinstance(body, dict) else None
relative_paths = None started, err = app_dash.start_import_async(relative_paths=relative_paths)
elif isinstance(body, dict): if not started:
relative_paths = body.get("paths", []) return jsonify({"status": "busy", "message": err}), 409
else: return jsonify({"status": "started"})
relative_paths = []
summary = app_dash.import_dashcam(relative_paths=relative_paths) @app.route('/api/dash/import/status')
status_code = ( def dash_import_status():
200 job = app_dash.get_import_status()
if summary["status"] == "ok" if job is None:
else 400 return jsonify({"status": "none"})
if summary["status"] == "invalid_selection" return jsonify(job)
else 409
)
return jsonify(summary), status_code
@app.route('/api/dash/files') @app.route('/api/dash/files')
def dash_files(): def dash_files():
+34
View File
@@ -37,6 +37,10 @@ _local_video_state_lock = threading.Lock()
_local_videos_in_use = {} _local_videos_in_use = {}
_local_videos_deleting = set() _local_videos_deleting = set()
# --- Async import job state ---
_import_lock = threading.Lock()
_import_job = None # dict: {status, found, copied, skipped, errors, message} or None
def begin_local_video_use(relative_path): def begin_local_video_use(relative_path):
with _local_video_state_lock: with _local_video_state_lock:
@@ -569,5 +573,35 @@ def import_dashcam(
unmount_device(mount_point) unmount_device(mount_point)
def start_import_async(relative_paths=None):
"""Start a background import thread. Returns (True, None) if started,
or (False, message) if an import is already running."""
global _import_job
with _import_lock:
if _import_job and _import_job.get("status") == "running":
return False, "An import is already in progress."
_import_job = {
"status": "running",
"found": 0, "copied": 0, "skipped": 0,
"errors": [], "message": "",
}
def _run():
global _import_job
result = import_dashcam(relative_paths=relative_paths)
with _import_lock:
_import_job = result
t = threading.Thread(target=_run, daemon=True)
t.start()
return True, None
def get_import_status():
"""Return the current import job state dict, or None if no job has been run."""
with _import_lock:
return dict(_import_job) if _import_job else None
if __name__ == "__main__": if __name__ == "__main__":
print(import_dashcam()) print(import_dashcam())
+69 -48
View File
@@ -17,10 +17,12 @@ const filesRefreshBtn = document.getElementById('dash-files-refresh-btn');
const selectAllBtn = document.getElementById('dash-select-all-btn'); const selectAllBtn = document.getElementById('dash-select-all-btn');
const importSelectedBtn = document.getElementById('dash-import-selected-btn'); const importSelectedBtn = document.getElementById('dash-import-selected-btn');
const deleteAllBtn = document.getElementById('dash-delete-all-btn'); const deleteAllBtn = document.getElementById('dash-delete-all-btn');
let importInProgress = false; let importInProgress = false;
let importStartTime = null; let importStartTime = null;
let importTotal = 0; let importTotal = 0;
let importEtaInterval = null; let importEtaInterval = null;
let importPollInterval = null;
function showImportProgress(msg, total) { function showImportProgress(msg, total) {
importTotal = total || 0; importTotal = total || 0;
@@ -42,6 +44,8 @@ function showImportProgress(msg, total) {
function showImportResult(msg) { function showImportResult(msg) {
clearInterval(importEtaInterval); clearInterval(importEtaInterval);
clearInterval(importPollInterval);
importPollInterval = null;
importStatusBar.style.display = 'none'; importStatusBar.style.display = 'none';
importResult.textContent = msg; importResult.textContent = msg;
importResultBar.style.display = 'block'; importResultBar.style.display = 'block';
@@ -134,6 +138,7 @@ function renderDashcamFiles(summary) {
actionsTd.className = 'dash-actions-td'; actionsTd.className = 'dash-actions-td';
const actionsDiv = document.createElement('div'); const actionsDiv = document.createElement('div');
actionsDiv.className = 'data-list-actions'; actionsDiv.className = 'data-list-actions';
const importButton = document.createElement('button'); const importButton = document.createElement('button');
importButton.type = 'button'; importButton.type = 'button';
importButton.className = 'btn btn-sm btn-outline-primary btn-icon-mobile'; importButton.className = 'btn btn-sm btn-outline-primary btn-icon-mobile';
@@ -190,34 +195,62 @@ function deleteDashcamFile(path) {
}); });
} }
function describeImportResult(summary) { function describeImportResult(job) {
let text = `Selected ${summary.found} video(s), copied ${summary.copied}, skipped ${summary.skipped} (already imported).`; let text = `Copied ${job.copied}, skipped ${job.skipped} (already imported).`;
if (summary.errors && summary.errors.length) { if (job.errors && job.errors.length) {
text += ` Errors: ${summary.errors.join('; ')}`; text += ` Errors: ${job.errors.join('; ')}`;
} }
return text; return text;
} }
function importDashcamFiles(paths) { function pollImportStatus() {
showImportProgress(`Importing ${paths.length} video(s)…`, paths.length); fetch('/api/dash/import/status')
.then(r => r.json())
.then(job => {
if (!job || job.status === 'none' || job.status === 'running') return;
clearInterval(importPollInterval);
importPollInterval = null;
setImportBusy(false);
if (job.status === 'ok') {
showImportResult(describeImportResult(job));
refreshDashcamFiles();
} else {
showImportResult(job.message || `Import failed: ${job.status}`);
}
})
.catch(() => {}); // ignore transient network errors while polling
}
function startImport(paths) {
const label = paths ? `Importing ${paths.length} video(s)…` : 'Importing all videos… this can take a while.';
showImportProgress(label, paths ? paths.length : 0);
setImportBusy(true); setImportBusy(true);
return fetchJson('/api/dash/import', {
const body = paths ? JSON.stringify({ paths }) : undefined;
fetch('/api/dash/import', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: body ? { 'Content-Type': 'application/json' } : {},
body: JSON.stringify({ paths }), body,
}).then(summary => { })
showImportResult(describeImportResult(summary)); .then(r => r.json())
paths.forEach(path => { .then(data => {
const checkbox = Array.from( if (data.status === 'busy') {
fileListBody.querySelectorAll('[data-select-path]') showImportResult(data.message || 'Import already in progress.');
).find(candidate => candidate.dataset.selectPath === path); setImportBusy(false);
if (checkbox) checkbox.checked = false; return;
}
if (data.status !== 'started') {
showImportResult(data.message || `Import error: ${data.status}`);
setImportBusy(false);
return;
}
clearInterval(importPollInterval);
importPollInterval = setInterval(pollImportStatus, 3000);
})
.catch(err => {
showImportResult(`Import request failed: ${err}`);
setImportBusy(false);
}); });
return summary;
}).catch(err => {
showImportResult(`Selected import failed: ${err.message}`);
return null;
}).finally(() => setImportBusy(false));
} }
function describeDevice(device) { function describeDevice(device) {
@@ -245,37 +278,13 @@ function scanForDashcam() {
}); });
} }
function importDashcam() {
showImportProgress('Importing all videos… this can take a while.', 0);
setImportBusy(true);
fetch('/api/dash/import', { method: 'POST' })
.then(response => response.json())
.then(summary => {
if (summary.status !== 'ok') {
showImportResult(summary.message || ('Import failed: ' + summary.status));
return;
}
let text = `Found ${summary.found} video(s), copied ${summary.copied}, skipped ${summary.skipped}.`;
if (summary.errors && summary.errors.length > 0) {
text += ` Errors: ${summary.errors.join('; ')}`;
}
showImportResult(text);
refreshDashcamFiles();
})
.catch(err => {
showImportResult('Error while importing: ' + err);
})
.finally(() => setImportBusy(false));
}
scanBtn.addEventListener('click', scanForDashcam); scanBtn.addEventListener('click', scanForDashcam);
importBtn.addEventListener('click', importDashcam); importBtn.addEventListener('click', () => startImport(null));
filesRefreshBtn.addEventListener('click', refreshDashcamFiles); filesRefreshBtn.addEventListener('click', refreshDashcamFiles);
fileListBody.addEventListener('click', event => { fileListBody.addEventListener('click', event => {
const importButton = event.target.closest('[data-import-path]'); const importButton = event.target.closest('[data-import-path]');
if (importButton) { if (importButton) {
importDashcamFiles([importButton.dataset.importPath]); startImport([importButton.dataset.importPath]);
return; return;
} }
@@ -295,7 +304,7 @@ fileListBody.addEventListener('change', event => {
importSelectedBtn.addEventListener('click', () => { importSelectedBtn.addEventListener('click', () => {
const paths = selectedPaths(); const paths = selectedPaths();
if (paths.length) { if (paths.length) {
importDashcamFiles(paths); startImport(paths);
} }
}); });
selectAllBtn.addEventListener('click', () => { selectAllBtn.addEventListener('click', () => {
@@ -322,6 +331,18 @@ deleteAllBtn.addEventListener('click', () => {
}); });
}); });
// Check if an import is already running when page loads (e.g. after refresh mid-import)
fetch('/api/dash/import/status')
.then(r => r.json())
.then(job => {
if (job && job.status === 'running') {
showImportProgress('Import in progress…', 0);
setImportBusy(true);
importPollInterval = setInterval(pollImportStatus, 3000);
}
})
.catch(() => {});
// Auto-scan once when the page loads. // Auto-scan once when the page loads.
scanForDashcam(); scanForDashcam();
refreshDashcamFiles(); refreshDashcamFiles();