diff --git a/PLAN.md b/PLAN.md index b8a70c2..fd55843 100644 --- a/PLAN.md +++ b/PLAN.md @@ -855,6 +855,18 @@ Phase 15 shared list component: to the original 87-file count and byte total, left every real video untouched, unmounted `/mnt/dashcam`, and kept the service active. Bulk delete uses the same validated per-file removal path but was not run against the real card because it would intentionally erase all footage. +- **Selected import enhancement and real-hardware verification (2026-08-09)**: added per-row + Import buttons, checkboxes, Select all/Clear selection, and Import selected so importing is no + longer all-or-nothing. The backend validates every selected path with the same mount-boundary + protections as deletion, de-duplicates selections, preserves relative folders, tolerates a + stale/missing file while importing the remaining valid selection, and keeps the existing + same-size skip behavior. Before committing, paused background dashcam uploads, created a + disposable `YARIS_OBD_IMPORT_TEST.MP4` on the physical card, selected it in the real mobile-width + UI, and confirmed exactly one file copied with identical contents. A row-level repeat correctly + skipped the existing copy; Select all selected all 88 visible rows and Clear selection reset the + action. Removed both disposable copies, restored uploads, and verified the card returned to its + original 87 files/29,615,111,758 bytes unmounted. The refined UI was then re-tested against a + real existing video: it reported one skip, cleared the checkbox, and re-enabled all controls. ### Phase 17: Local imported-video browser + player — 🚧 PLANNED Same file-browser treatment for videos already imported to the Pi's own disk diff --git a/app.py b/app.py index 0fd6a3d..3c6e609 100644 --- a/app.py +++ b/app.py @@ -35,8 +35,22 @@ def dash_devices(): @app.route('/api/dash/import', methods=['POST']) def dash_import(): - summary = app_dash.import_dashcam() - return jsonify(summary) + 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 @app.route('/api/dash/files') def dash_files(): diff --git a/modules/app_dash.py b/modules/app_dash.py index 11a30b7..17e0774 100644 --- a/modules/app_dash.py +++ b/modules/app_dash.py @@ -383,10 +383,8 @@ def delete_dashcam_files(relative_path=None, delete_all=False, mount_point=DEFAU return result -def copy_files(source_folder, destination_folder): - """Copy every video file found under source_folder into destination_folder, - preserving its relative sub-folder path (dashcams often split files into - date/event sub-folders - flattening them risks filename collisions). +def copy_files(source_folder, destination_folder, relative_paths=None): + """Copy selected videos, or every video when relative_paths is omitted. Files already present at the destination (same relative path, same size) are skipped so re-running an import doesn't re-copy everything. @@ -394,11 +392,43 @@ def copy_files(source_folder, destination_folder): Returns a summary dict: {found, copied, skipped, errors}. """ os.makedirs(destination_folder, exist_ok=True) + errors = [] + + if relative_paths is None: + videos = find_video_files(source_folder) + else: + if not isinstance(relative_paths, list) or not relative_paths: + return { + "found": 0, + "copied": 0, + "skipped": 0, + "errors": ["Select at least one video to import."], + "invalid_selection": True, + } + + videos = [] + seen = set() + for relative_path in relative_paths: + if relative_path in seen: + continue + seen.add(relative_path) + try: + abs_path = _safe_video_path(source_folder, relative_path) + except ValueError as e: + return { + "found": 0, + "copied": 0, + "skipped": 0, + "errors": [str(e)], + "invalid_selection": True, + } + except FileNotFoundError: + errors.append(f"Video not found: {relative_path}") + continue + videos.append((abs_path, relative_path)) - videos = find_video_files(source_folder) copied = 0 skipped = 0 - errors = [] for abs_path, rel_path in videos: dest_path = os.path.join(destination_folder, rel_path) @@ -417,7 +447,11 @@ def copy_files(source_folder, destination_folder): return {"found": len(videos), "copied": copied, "skipped": skipped, "errors": errors} -def import_dashcam(destination_folder=DEFAULT_DESTINATION, mount_point=DEFAULT_MOUNT_POINT): +def import_dashcam( + destination_folder=DEFAULT_DESTINATION, + mount_point=DEFAULT_MOUNT_POINT, + relative_paths=None, +): """Full detect -> mount -> copy -> unmount pipeline. Returns a summary dict describing what happened, always unmounting the @@ -440,8 +474,12 @@ def import_dashcam(destination_folder=DEFAULT_DESTINATION, mount_point=DEFAULT_M } try: - summary = copy_files(source, destination_folder) - summary["status"] = "ok" + summary = copy_files(source, destination_folder, relative_paths) + summary["status"] = ( + "invalid_selection" if summary.pop("invalid_selection", False) else "ok" + ) + if summary["status"] == "invalid_selection": + summary["message"] = summary["errors"][0] summary["device"] = device_path return summary finally: diff --git a/readme.md b/readme.md index 11c96b6..dca5d68 100644 --- a/readme.md +++ b/readme.md @@ -43,7 +43,8 @@ i have a dream - 🎥 **Dashcam file retrieval** — detects a USB dashcam automatically (any brand/folder layout, no hardcoded device path), mounts it read-only, and safely copies footage locally. Verified against a real 28GB/87-file import. The web UI can also browse, sort, and delete individual or - all videos directly from the connected dashcam with confirmed, temporary read-write operations + all videos directly from the connected dashcam with confirmed, temporary read-write operations, + plus import one file or any checkbox selection instead of always importing the whole card - ☁️ **Remote upload** — a self-hosted server (Flask + Postgres, `server/`) receives trip/GPS logs and dashcam footage over the internet (via Cloudflare Tunnel or a plain port-forward). Dashcam videos upload in resumable 40MB chunks (works around tunnel body-size limits and keeps diff --git a/static/js/dash.js b/static/js/dash.js index 8562b69..905082c 100644 --- a/static/js/dash.js +++ b/static/js/dash.js @@ -10,12 +10,16 @@ const importBtn = document.getElementById('dash-import-btn'); const fileSummary = document.getElementById('dash-file-summary'); const fileListBody = document.querySelector('#dashcam-file-list [data-data-list-body]'); 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; function fetchJson(url, options) { return fetch(url, options).then(response => response.json().then(data => { if (!response.ok) { - throw new Error(data.message || data.status || `HTTP ${response.status}`); + const details = data.errors && data.errors.length ? data.errors.join('; ') : null; + throw new Error(data.message || details || data.status || `HTTP ${response.status}`); } return data; })); @@ -36,6 +40,33 @@ function createCell(text) { return cell; } +function selectedPaths() { + return Array.from(fileListBody.querySelectorAll('[data-select-path]:checked')) + .map(checkbox => checkbox.dataset.selectPath); +} + +function updateSelectionControls() { + const checkboxes = Array.from(fileListBody.querySelectorAll('[data-select-path]')); + const selectedCount = selectedPaths().length; + importSelectedBtn.disabled = importInProgress || selectedCount === 0; + selectAllBtn.disabled = checkboxes.length === 0; + selectAllBtn.textContent = checkboxes.length > 0 && selectedCount === checkboxes.length + ? 'Clear selection' + : 'Select all'; + importSelectedBtn.innerHTML = selectedCount + ? `Import selected (${selectedCount})` + : 'Import selected'; +} + +function setImportBusy(busy) { + importInProgress = busy; + importBtn.disabled = busy; + fileListBody.querySelectorAll('[data-import-path]').forEach(button => { + button.disabled = busy; + }); + updateSelectionControls(); +} + function renderDashcamFiles(summary) { fileListBody.replaceChildren(); if (summary.status !== 'ok') { @@ -51,12 +82,29 @@ function renderDashcamFiles(summary) { row.dataset.path = file.path; row.dataset.size = file.size; row.dataset.modified = file.modified_at; + + const selection = document.createElement('td'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'form-check-input'; + checkbox.dataset.selectPath = file.path; + checkbox.setAttribute('aria-label', `Select ${file.path}`); + selection.appendChild(checkbox); + row.appendChild(selection); + row.appendChild(createCell(file.path)); row.appendChild(createCell(formatBytes(file.size))); row.appendChild(createCell(new Date(file.modified_at * 1000).toLocaleString())); const actions = document.createElement('td'); actions.className = 'data-list-actions'; + const importButton = document.createElement('button'); + importButton.type = 'button'; + importButton.className = 'btn btn-sm btn-outline-primary'; + importButton.dataset.importPath = file.path; + importButton.innerHTML = 'Import'; + actions.appendChild(importButton); + const deleteButton = document.createElement('button'); deleteButton.type = 'button'; deleteButton.className = 'btn btn-sm btn-outline-danger'; @@ -73,6 +121,7 @@ function renderDashcamFiles(summary) { fileSummary.textContent += ` (${summary.errors.length} file error(s))`; } deleteAllBtn.disabled = files.length === 0; + updateSelectionControls(); } function refreshDashcamFiles() { @@ -84,6 +133,7 @@ function refreshDashcamFiles() { fileListBody.replaceChildren(); fileSummary.textContent = `Unable to list dashcam files: ${err.message}`; deleteAllBtn.disabled = true; + updateSelectionControls(); }) .finally(() => { filesRefreshBtn.disabled = false; @@ -103,6 +153,38 @@ 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('; ')}`; + } + return text; +} + +function importDashcamFiles(paths) { + importStatus.textContent = `Importing ${paths.length} selected video(s)...`; + setImportBusy(true); + return fetchJson('/api/dash/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paths }), + }).then(summary => { + importStatus.textContent = 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; + } + }); + return summary; + }).catch(err => { + importStatus.textContent = `Selected import failed: ${err.message}`; + return null; + }).finally(() => setImportBusy(false)); +} + function describeDevice(device) { const label = device.label ? ` "${device.label}"` : ''; return `${device.path}${label} (${device.size}, ${device.fstype || 'unknown fs'})`; @@ -130,7 +212,7 @@ function scanForDashcam() { function importDashcam() { importStatus.textContent = 'Importing... this can take a while for large videos.'; - importBtn.disabled = true; + setImportBusy(true); fetch('/api/dash/import', { method: 'POST' }) .then(response => response.json()) @@ -150,7 +232,7 @@ function importDashcam() { importStatus.textContent = 'Error while importing: ' + err; }) .finally(() => { - importBtn.disabled = false; + setImportBusy(false); }); } @@ -158,6 +240,12 @@ scanBtn.addEventListener('click', scanForDashcam); importBtn.addEventListener('click', importDashcam); filesRefreshBtn.addEventListener('click', refreshDashcamFiles); fileListBody.addEventListener('click', event => { + const importButton = event.target.closest('[data-import-path]'); + if (importButton) { + importDashcamFiles([importButton.dataset.importPath]); + return; + } + const deleteButton = event.target.closest('[data-delete-path]'); if (deleteButton) { deleteButton.disabled = true; @@ -166,6 +254,25 @@ fileListBody.addEventListener('click', event => { }); } }); +fileListBody.addEventListener('change', event => { + if (event.target.matches('[data-select-path]')) { + updateSelectionControls(); + } +}); +importSelectedBtn.addEventListener('click', () => { + const paths = selectedPaths(); + if (paths.length) { + importDashcamFiles(paths); + } +}); +selectAllBtn.addEventListener('click', () => { + const checkboxes = Array.from(fileListBody.querySelectorAll('[data-select-path]')); + const shouldSelect = checkboxes.some(checkbox => !checkbox.checked); + checkboxes.forEach(checkbox => { + checkbox.checked = shouldSelect; + }); + updateSelectionControls(); +}); deleteAllBtn.addEventListener('click', () => { if (!window.confirm(deleteAllBtn.dataset.confirm)) { return; diff --git a/templates/dash.html b/templates/dash.html index 149bb09..3aee7b1 100644 --- a/templates/dash.html +++ b/templates/dash.html @@ -25,6 +25,12 @@ + +