Add selected dashcam imports

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-09 18:34:52 -04:00
co-authored by Copilot
parent e058ea8a41
commit c57457b934
6 changed files with 194 additions and 15 deletions
+12
View File
@@ -855,6 +855,18 @@ Phase 15 shared list component:
to the original 87-file count and byte total, left every real video untouched, unmounted 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 `/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. 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 ### Phase 17: Local imported-video browser + player — 🚧 PLANNED
Same file-browser treatment for videos already imported to the Pi's own disk Same file-browser treatment for videos already imported to the Pi's own disk
+16 -2
View File
@@ -35,8 +35,22 @@ def dash_devices():
@app.route('/api/dash/import', methods=['POST']) @app.route('/api/dash/import', methods=['POST'])
def dash_import(): def dash_import():
summary = app_dash.import_dashcam() body = request.get_json(silent=True)
return jsonify(summary) 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') @app.route('/api/dash/files')
def dash_files(): def dash_files():
+46 -8
View File
@@ -383,10 +383,8 @@ def delete_dashcam_files(relative_path=None, delete_all=False, mount_point=DEFAU
return result return result
def copy_files(source_folder, destination_folder): def copy_files(source_folder, destination_folder, relative_paths=None):
"""Copy every video file found under source_folder into destination_folder, """Copy selected videos, or every video when relative_paths is omitted.
preserving its relative sub-folder path (dashcams often split files into
date/event sub-folders - flattening them risks filename collisions).
Files already present at the destination (same relative path, same size) Files already present at the destination (same relative path, same size)
are skipped so re-running an import doesn't re-copy everything. 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}. Returns a summary dict: {found, copied, skipped, errors}.
""" """
os.makedirs(destination_folder, exist_ok=True) os.makedirs(destination_folder, exist_ok=True)
errors = []
if relative_paths is None:
videos = find_video_files(source_folder) 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))
copied = 0 copied = 0
skipped = 0 skipped = 0
errors = []
for abs_path, rel_path in videos: for abs_path, rel_path in videos:
dest_path = os.path.join(destination_folder, rel_path) 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} 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. """Full detect -> mount -> copy -> unmount pipeline.
Returns a summary dict describing what happened, always unmounting the 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: try:
summary = copy_files(source, destination_folder) summary = copy_files(source, destination_folder, relative_paths)
summary["status"] = "ok" 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 summary["device"] = device_path
return summary return summary
finally: finally:
+2 -1
View File
@@ -43,7 +43,8 @@ i have a dream
- 🎥 **Dashcam file retrieval** — detects a USB dashcam automatically (any brand/folder layout, - 🎥 **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 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 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 - ☁️ **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). 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 Dashcam videos upload in resumable 40MB chunks (works around tunnel body-size limits and keeps
+110 -3
View File
@@ -10,12 +10,16 @@ const importBtn = document.getElementById('dash-import-btn');
const fileSummary = document.getElementById('dash-file-summary'); const fileSummary = document.getElementById('dash-file-summary');
const fileListBody = document.querySelector('#dashcam-file-list [data-data-list-body]'); const fileListBody = document.querySelector('#dashcam-file-list [data-data-list-body]');
const filesRefreshBtn = document.getElementById('dash-files-refresh-btn'); 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'); const deleteAllBtn = document.getElementById('dash-delete-all-btn');
let importInProgress = false;
function fetchJson(url, options) { function fetchJson(url, options) {
return fetch(url, options).then(response => response.json().then(data => { return fetch(url, options).then(response => response.json().then(data => {
if (!response.ok) { 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; return data;
})); }));
@@ -36,6 +40,33 @@ function createCell(text) {
return cell; 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
? `<i class="fas fa-download me-1"></i>Import selected (${selectedCount})`
: '<i class="fas fa-download me-1"></i>Import selected';
}
function setImportBusy(busy) {
importInProgress = busy;
importBtn.disabled = busy;
fileListBody.querySelectorAll('[data-import-path]').forEach(button => {
button.disabled = busy;
});
updateSelectionControls();
}
function renderDashcamFiles(summary) { function renderDashcamFiles(summary) {
fileListBody.replaceChildren(); fileListBody.replaceChildren();
if (summary.status !== 'ok') { if (summary.status !== 'ok') {
@@ -51,12 +82,29 @@ function renderDashcamFiles(summary) {
row.dataset.path = file.path; row.dataset.path = file.path;
row.dataset.size = file.size; row.dataset.size = file.size;
row.dataset.modified = file.modified_at; 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(file.path));
row.appendChild(createCell(formatBytes(file.size))); row.appendChild(createCell(formatBytes(file.size)));
row.appendChild(createCell(new Date(file.modified_at * 1000).toLocaleString())); row.appendChild(createCell(new Date(file.modified_at * 1000).toLocaleString()));
const actions = document.createElement('td'); const actions = document.createElement('td');
actions.className = 'data-list-actions'; 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 = '<i class="fas fa-download me-1"></i>Import';
actions.appendChild(importButton);
const deleteButton = document.createElement('button'); const deleteButton = document.createElement('button');
deleteButton.type = 'button'; deleteButton.type = 'button';
deleteButton.className = 'btn btn-sm btn-outline-danger'; deleteButton.className = 'btn btn-sm btn-outline-danger';
@@ -73,6 +121,7 @@ function renderDashcamFiles(summary) {
fileSummary.textContent += ` (${summary.errors.length} file error(s))`; fileSummary.textContent += ` (${summary.errors.length} file error(s))`;
} }
deleteAllBtn.disabled = files.length === 0; deleteAllBtn.disabled = files.length === 0;
updateSelectionControls();
} }
function refreshDashcamFiles() { function refreshDashcamFiles() {
@@ -84,6 +133,7 @@ function refreshDashcamFiles() {
fileListBody.replaceChildren(); fileListBody.replaceChildren();
fileSummary.textContent = `Unable to list dashcam files: ${err.message}`; fileSummary.textContent = `Unable to list dashcam files: ${err.message}`;
deleteAllBtn.disabled = true; deleteAllBtn.disabled = true;
updateSelectionControls();
}) })
.finally(() => { .finally(() => {
filesRefreshBtn.disabled = false; 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) { function describeDevice(device) {
const label = device.label ? ` "${device.label}"` : ''; const label = device.label ? ` "${device.label}"` : '';
return `${device.path}${label} (${device.size}, ${device.fstype || 'unknown fs'})`; return `${device.path}${label} (${device.size}, ${device.fstype || 'unknown fs'})`;
@@ -130,7 +212,7 @@ function scanForDashcam() {
function importDashcam() { function importDashcam() {
importStatus.textContent = 'Importing... this can take a while for large videos.'; importStatus.textContent = 'Importing... this can take a while for large videos.';
importBtn.disabled = true; setImportBusy(true);
fetch('/api/dash/import', { method: 'POST' }) fetch('/api/dash/import', { method: 'POST' })
.then(response => response.json()) .then(response => response.json())
@@ -150,7 +232,7 @@ function importDashcam() {
importStatus.textContent = 'Error while importing: ' + err; importStatus.textContent = 'Error while importing: ' + err;
}) })
.finally(() => { .finally(() => {
importBtn.disabled = false; setImportBusy(false);
}); });
} }
@@ -158,6 +240,12 @@ scanBtn.addEventListener('click', scanForDashcam);
importBtn.addEventListener('click', importDashcam); importBtn.addEventListener('click', importDashcam);
filesRefreshBtn.addEventListener('click', refreshDashcamFiles); filesRefreshBtn.addEventListener('click', refreshDashcamFiles);
fileListBody.addEventListener('click', event => { 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]'); const deleteButton = event.target.closest('[data-delete-path]');
if (deleteButton) { if (deleteButton) {
deleteButton.disabled = true; 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', () => { deleteAllBtn.addEventListener('click', () => {
if (!window.confirm(deleteAllBtn.dataset.confirm)) { if (!window.confirm(deleteAllBtn.dataset.confirm)) {
return; return;
+7
View File
@@ -25,6 +25,12 @@
<button id="dash-files-refresh-btn" class="btn btn-sm btn-secondary" type="button"> <button id="dash-files-refresh-btn" class="btn btn-sm btn-secondary" type="button">
<i class="fas fa-sync-alt me-1"></i>Refresh <i class="fas fa-sync-alt me-1"></i>Refresh
</button> </button>
<button id="dash-select-all-btn" class="btn btn-sm btn-outline-secondary" type="button" disabled>
Select all
</button>
<button id="dash-import-selected-btn" class="btn btn-sm btn-primary" type="button" disabled>
<i class="fas fa-download me-1"></i>Import selected
</button>
<button id="dash-delete-all-btn" class="btn btn-sm btn-danger" type="button" <button id="dash-delete-all-btn" class="btn btn-sm btn-danger" type="button"
data-confirm="Permanently delete every video from the dashcam? This cannot be undone." disabled> data-confirm="Permanently delete every video from the dashcam? This cannot be undone." disabled>
<i class="fas fa-trash me-1"></i>Delete all <i class="fas fa-trash me-1"></i>Delete all
@@ -35,6 +41,7 @@
{{ data_table( {{ data_table(
"dashcam-file-list", "dashcam-file-list",
[ [
{"key": "selected", "label": "Select", "sortable": false},
{"key": "path", "label": "File", "sortable": true}, {"key": "path", "label": "File", "sortable": true},
{"key": "size", "label": "Size", "sortable": true}, {"key": "size", "label": "Size", "sortable": true},
{"key": "modified", "label": "Modified", "sortable": true}, {"key": "modified", "label": "Modified", "sortable": true},