diff --git a/app.py b/app.py index 0bd12ce..3d274c4 100644 --- a/app.py +++ b/app.py @@ -97,21 +97,18 @@ def dash_devices(): @app.route('/api/dash/import', methods=['POST']) def dash_import(): body = request.get_json(silent=True) - if body is None: - relative_paths = None - elif isinstance(body, dict): - relative_paths = body.get("paths", []) - else: - relative_paths = [] - summary = app_dash.import_dashcam(relative_paths=relative_paths) - status_code = ( - 200 - if summary["status"] == "ok" - else 400 - if summary["status"] == "invalid_selection" - else 409 - ) - return jsonify(summary), status_code + relative_paths = body.get("paths") if isinstance(body, dict) else None + started, err = app_dash.start_import_async(relative_paths=relative_paths) + if not started: + return jsonify({"status": "busy", "message": err}), 409 + return jsonify({"status": "started"}) + +@app.route('/api/dash/import/status') +def dash_import_status(): + job = app_dash.get_import_status() + if job is None: + return jsonify({"status": "none"}) + return jsonify(job) @app.route('/api/dash/files') def dash_files(): diff --git a/modules/app_dash.py b/modules/app_dash.py index 1ff4e9d..ecf65e4 100644 --- a/modules/app_dash.py +++ b/modules/app_dash.py @@ -37,6 +37,10 @@ _local_video_state_lock = threading.Lock() _local_videos_in_use = {} _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): with _local_video_state_lock: @@ -569,5 +573,35 @@ def import_dashcam( 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__": print(import_dashcam()) diff --git a/static/js/dash.js b/static/js/dash.js index 3fa7fdc..f57d4b3 100644 --- a/static/js/dash.js +++ b/static/js/dash.js @@ -17,10 +17,12 @@ const filesRefreshBtn = document.getElementById('dash-files-refresh-btn'); const selectAllBtn = document.getElementById('dash-select-all-btn'); const importSelectedBtn = document.getElementById('dash-import-selected-btn'); const deleteAllBtn = document.getElementById('dash-delete-all-btn'); + let importInProgress = false; let importStartTime = null; let importTotal = 0; let importEtaInterval = null; +let importPollInterval = null; function showImportProgress(msg, total) { importTotal = total || 0; @@ -42,6 +44,8 @@ function showImportProgress(msg, total) { function showImportResult(msg) { clearInterval(importEtaInterval); + clearInterval(importPollInterval); + importPollInterval = null; importStatusBar.style.display = 'none'; importResult.textContent = msg; importResultBar.style.display = 'block'; @@ -134,6 +138,7 @@ function renderDashcamFiles(summary) { actionsTd.className = 'dash-actions-td'; const actionsDiv = document.createElement('div'); actionsDiv.className = 'data-list-actions'; + const importButton = document.createElement('button'); importButton.type = 'button'; importButton.className = 'btn btn-sm btn-outline-primary btn-icon-mobile'; @@ -190,34 +195,62 @@ function deleteDashcamFile(path) { }); } -function describeImportResult(summary) { - let text = `Selected ${summary.found} video(s), copied ${summary.copied}, skipped ${summary.skipped} (already imported).`; - if (summary.errors && summary.errors.length) { - text += ` Errors: ${summary.errors.join('; ')}`; +function describeImportResult(job) { + let text = `Copied ${job.copied}, skipped ${job.skipped} (already imported).`; + if (job.errors && job.errors.length) { + text += ` Errors: ${job.errors.join('; ')}`; } return text; } -function importDashcamFiles(paths) { - showImportProgress(`Importing ${paths.length} video(s)…`, paths.length); +function pollImportStatus() { + 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); - return fetchJson('/api/dash/import', { + + const body = paths ? JSON.stringify({ paths }) : undefined; + fetch('/api/dash/import', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ paths }), - }).then(summary => { - showImportResult(describeImportResult(summary)); - paths.forEach(path => { - const checkbox = Array.from( - fileListBody.querySelectorAll('[data-select-path]') - ).find(candidate => candidate.dataset.selectPath === path); - if (checkbox) checkbox.checked = false; + headers: body ? { 'Content-Type': 'application/json' } : {}, + body, + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'busy') { + showImportResult(data.message || 'Import already in progress.'); + setImportBusy(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) { @@ -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); -importBtn.addEventListener('click', importDashcam); +importBtn.addEventListener('click', () => startImport(null)); filesRefreshBtn.addEventListener('click', refreshDashcamFiles); fileListBody.addEventListener('click', event => { const importButton = event.target.closest('[data-import-path]'); if (importButton) { - importDashcamFiles([importButton.dataset.importPath]); + startImport([importButton.dataset.importPath]); return; } @@ -295,7 +304,7 @@ fileListBody.addEventListener('change', event => { importSelectedBtn.addEventListener('click', () => { const paths = selectedPaths(); if (paths.length) { - importDashcamFiles(paths); + startImport(paths); } }); 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. scanForDashcam(); refreshDashcamFiles();