Add selected dashcam imports
Co-authored-by: Copilot <[email protected]>
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
+47
-9
@@ -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)
|
||||||
|
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
|
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:
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
Reference in New Issue
Block a user