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:
@@ -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():
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user