From 5a520bf0223929dd3004acc8a70e7c4c7c6de731 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Sun, 9 Aug 2026 18:16:59 -0400 Subject: [PATCH] Add dashcam USB file browser Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app.py | 23 ++++ modules/app_dash.py | 251 ++++++++++++++++++++++++++++++++++++++------ static/js/dash.js | 122 +++++++++++++++++++++ templates/dash.html | 31 ++++++ 4 files changed, 396 insertions(+), 31 deletions(-) diff --git a/app.py b/app.py index 3bd7356..0fd6a3d 100644 --- a/app.py +++ b/app.py @@ -38,6 +38,29 @@ def dash_import(): summary = app_dash.import_dashcam() return jsonify(summary) +@app.route('/api/dash/files') +def dash_files(): + summary = app_dash.list_dashcam_files() + return jsonify(summary) + +@app.route('/api/dash/files', methods=['DELETE']) +def dash_delete_all(): + summary = app_dash.delete_dashcam_files(delete_all=True) + status_code = 200 if summary["status"] in {"ok", "partial"} else 409 + return jsonify(summary), status_code + +@app.route('/api/dash/file', methods=['DELETE']) +def dash_delete_file(): + body = request.get_json(silent=True) or {} + summary = app_dash.delete_dashcam_files(relative_path=body.get("path")) + status_codes = { + "ok": 200, + "partial": 409, + "invalid_path": 400, + "not_found": 404, + } + return jsonify(summary), status_codes.get(summary["status"], 409) + @app.route('/api/upload/status') def upload_status(): return jsonify(app_upload.get_status()) diff --git a/modules/app_dash.py b/modules/app_dash.py index f684e8d..b128fd9 100644 --- a/modules/app_dash.py +++ b/modules/app_dash.py @@ -20,6 +20,7 @@ import json import os import shutil import subprocess +import threading VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".ts", ".mkv"} @@ -31,6 +32,7 @@ DEFAULT_MOUNT_POINT = "/mnt/dashcam" DEFAULT_DESTINATION = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "dash", "videos" ) +_device_lock = threading.Lock() def list_usb_storage_partitions(): @@ -107,8 +109,8 @@ def find_dashcam_partition(): return (recognized or partitions)[0] -def mount_device(device_path, mount_point=DEFAULT_MOUNT_POINT): - """Mount device_path read-only at mount_point. Returns True on success. +def mount_device(device_path, mount_point=DEFAULT_MOUNT_POINT, read_only=True): + """Mount device_path at mount_point. Returns True on success. The app runs as an unprivileged user while /mnt is root-owned, so the mount point directory itself has to be created via sudo too (not just @@ -128,7 +130,14 @@ def mount_device(device_path, mount_point=DEFAULT_MOUNT_POINT): try: subprocess.run( - ["sudo", "mount", "-o", "ro", device_path, mount_point], + [ + "sudo", + "mount", + "-o", + "ro" if read_only else "rw", + device_path, + mount_point, + ], capture_output=True, text=True, check=True, @@ -139,6 +148,28 @@ def mount_device(device_path, mount_point=DEFAULT_MOUNT_POINT): return False +def remount_device(mount_point=DEFAULT_MOUNT_POINT, read_only=True): + """Remount an existing dashcam mount read-only or read-write.""" + try: + subprocess.run( + [ + "sudo", + "mount", + "-o", + f"remount,{'ro' if read_only else 'rw'}", + mount_point, + ], + capture_output=True, + text=True, + check=True, + ) + return True + except subprocess.CalledProcessError as e: + mode = "read-only" if read_only else "read-write" + print(f"Failed to remount {mount_point} {mode}: {e.stderr.strip()}") + return False + + def unmount_device(mount_point=DEFAULT_MOUNT_POINT): """Unmount mount_point. Returns True on success (or if already unmounted).""" try: @@ -170,6 +201,170 @@ def find_video_files(source_folder): return found +def _video_metadata(source_folder): + files = [] + errors = [] + total_size = 0 + for abs_path, rel_path in find_video_files(source_folder): + try: + stat = os.stat(abs_path) + except OSError as e: + errors.append(f"{rel_path}: {e}") + continue + files.append( + { + "path": rel_path, + "size": stat.st_size, + "modified_at": stat.st_mtime, + } + ) + total_size += stat.st_size + files.sort(key=lambda item: item["path"].lower()) + return files, total_size, errors + + +def _mounted_source(partition, mount_point, read_only): + """Return (source, mounted_here, remounted_existing) for one operation.""" + existing_mount = partition.get("mountpoint") + if existing_mount: + if not read_only and not remount_device(existing_mount, read_only=False): + return None, False, False + return existing_mount, False, not read_only + + if not mount_device(partition["path"], mount_point, read_only=read_only): + return None, False, False + return mount_point, True, False + + +def list_dashcam_files(mount_point=DEFAULT_MOUNT_POINT): + """List video files on the detected dashcam without copying them.""" + with _device_lock: + partition = find_dashcam_partition() + if partition is None: + return { + "status": "no_device", + "message": "No USB dashcam/storage device detected.", + "files": [], + "total_size": 0, + } + + source, mounted_here, _remounted = _mounted_source( + partition, mount_point, read_only=True + ) + if source is None: + return { + "status": "mount_failed", + "device": partition["path"], + "message": f"Failed to mount {partition['path']} read-only.", + "files": [], + "total_size": 0, + } + + try: + files, total_size, errors = _video_metadata(source) + return { + "status": "ok", + "device": partition, + "files": files, + "count": len(files), + "total_size": total_size, + "errors": errors, + } + finally: + if mounted_here: + unmount_device(mount_point) + + +def _safe_video_path(source_folder, relative_path): + if not isinstance(relative_path, str) or not relative_path.strip(): + raise ValueError("A non-empty relative video path is required.") + if os.path.isabs(relative_path): + raise ValueError("Video path must be relative to the dashcam.") + if os.path.splitext(relative_path)[1].lower() not in VIDEO_EXTENSIONS: + raise ValueError("Only recognized video files can be deleted.") + + source_real = os.path.realpath(source_folder) + target_real = os.path.realpath(os.path.join(source_real, relative_path)) + if os.path.commonpath([source_real, target_real]) != source_real: + raise ValueError("Video path escapes the dashcam mount.") + if not os.path.isfile(target_real): + raise FileNotFoundError(relative_path) + return target_real + + +def delete_dashcam_files(relative_path=None, delete_all=False, mount_point=DEFAULT_MOUNT_POINT): + """Delete one or all dashcam videos, restoring read-only state afterward.""" + if not delete_all and relative_path is None: + return {"status": "invalid_path", "message": "A video path is required."} + + with _device_lock: + partition = find_dashcam_partition() + if partition is None: + return {"status": "no_device", "message": "No USB dashcam/storage device detected."} + + source, mounted_here, remounted_existing = _mounted_source( + partition, mount_point, read_only=False + ) + if source is None: + return { + "status": "mount_failed", + "device": partition["path"], + "message": f"Failed to mount {partition['path']} read-write.", + } + + result = None + cleanup_ok = True + try: + if delete_all: + targets = find_video_files(source) + else: + try: + target = _safe_video_path(source, relative_path) + except ValueError as e: + result = {"status": "invalid_path", "message": str(e)} + except FileNotFoundError: + result = { + "status": "not_found", + "message": f"Video not found: {relative_path}", + } + else: + targets = [(target, relative_path)] + + if result is None: + deleted = [] + errors = [] + for _abs_path, rel_path in targets: + try: + safe_path = _safe_video_path(source, rel_path) + os.remove(safe_path) + deleted.append(rel_path) + except (OSError, ValueError) as e: + errors.append(f"{rel_path}: {e}") + + result = { + "status": "ok" if not errors else "partial", + "device": partition["path"], + "deleted": deleted, + "deleted_count": len(deleted), + "errors": errors, + } + finally: + if mounted_here: + cleanup_ok = unmount_device(mount_point) + if not cleanup_ok: + cleanup_ok = remount_device(mount_point, read_only=True) + elif remounted_existing: + cleanup_ok = remount_device(source, read_only=True) + + if not cleanup_ok: + return { + "status": "cleanup_failed", + "message": "Files were processed, but the dashcam could not be returned to read-only state.", + "operation": result, + } + 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 @@ -210,36 +405,30 @@ def import_dashcam(destination_folder=DEFAULT_DESTINATION, mount_point=DEFAULT_M Returns a summary dict describing what happened, always unmounting the device again afterwards (even if copying fails partway through). """ - partition = find_dashcam_partition() - if partition is None: - return {"status": "no_device", "message": "No USB dashcam/storage device detected."} + with _device_lock: + partition = find_dashcam_partition() + if partition is None: + return {"status": "no_device", "message": "No USB dashcam/storage device detected."} - device_path = partition["path"] + device_path = partition["path"] + source, mounted_here, _remounted = _mounted_source( + partition, mount_point, read_only=True + ) + if source is None: + return { + "status": "mount_failed", + "device": device_path, + "message": f"Failed to mount {device_path}.", + } - # Already mounted elsewhere (e.g. by the desktop environment) - read - # straight from there instead of trying to mount it again ourselves. - already_mounted_at = partition.get("mountpoint") - - mounted_here = False - try: - source = already_mounted_at - if not source: - if not mount_device(device_path, mount_point): - return { - "status": "mount_failed", - "device": device_path, - "message": f"Failed to mount {device_path}.", - } - mounted_here = True - source = mount_point - - summary = copy_files(source, destination_folder) - summary["status"] = "ok" - summary["device"] = device_path - return summary - finally: - if mounted_here: - unmount_device(mount_point) + try: + summary = copy_files(source, destination_folder) + summary["status"] = "ok" + summary["device"] = device_path + return summary + finally: + if mounted_here: + unmount_device(mount_point) if __name__ == "__main__": diff --git a/static/js/dash.js b/static/js/dash.js index 3e3ed33..8562b69 100644 --- a/static/js/dash.js +++ b/static/js/dash.js @@ -7,6 +7,101 @@ const deviceStatus = document.getElementById('dash-device-status'); const importStatus = document.getElementById('dash-import-status'); const scanBtn = document.getElementById('dash-scan-btn'); 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 deleteAllBtn = document.getElementById('dash-delete-all-btn'); + +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}`); + } + return data; + })); +} + +function formatBytes(bytes) { + if (bytes === 0) { + return '0 B'; + } + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const unit = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + return `${(bytes / (1024 ** unit)).toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`; +} + +function createCell(text) { + const cell = document.createElement('td'); + cell.textContent = text; + return cell; +} + +function renderDashcamFiles(summary) { + fileListBody.replaceChildren(); + if (summary.status !== 'ok') { + fileSummary.textContent = summary.message || `Unable to list files: ${summary.status}`; + deleteAllBtn.disabled = true; + return; + } + + const files = summary.files || []; + + files.forEach(file => { + const row = document.createElement('tr'); + row.dataset.path = file.path; + row.dataset.size = file.size; + row.dataset.modified = file.modified_at; + 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 deleteButton = document.createElement('button'); + deleteButton.type = 'button'; + deleteButton.className = 'btn btn-sm btn-outline-danger'; + deleteButton.dataset.deletePath = file.path; + deleteButton.dataset.confirm = `Permanently delete "${file.path}" from the dashcam?`; + deleteButton.innerHTML = 'Delete'; + actions.appendChild(deleteButton); + row.appendChild(actions); + fileListBody.appendChild(row); + }); + + fileSummary.textContent = `${files.length} video(s), ${formatBytes(summary.total_size || 0)} total`; + if (summary.errors && summary.errors.length) { + fileSummary.textContent += ` (${summary.errors.length} file error(s))`; + } + deleteAllBtn.disabled = files.length === 0; +} + +function refreshDashcamFiles() { + fileSummary.textContent = 'Scanning...'; + filesRefreshBtn.disabled = true; + return fetchJson('/api/dash/files') + .then(renderDashcamFiles) + .catch(err => { + fileListBody.replaceChildren(); + fileSummary.textContent = `Unable to list dashcam files: ${err.message}`; + deleteAllBtn.disabled = true; + }) + .finally(() => { + filesRefreshBtn.disabled = false; + }); +} + +function deleteDashcamFile(path) { + return fetchJson('/api/dash/file', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }), + }).then(result => { + fileSummary.textContent = `Deleted ${result.deleted_count} video(s). Refreshing...`; + return refreshDashcamFiles(); + }).catch(err => { + fileSummary.textContent = `Delete failed: ${err.message}`; + }); +} function describeDevice(device) { const label = device.label ? ` "${device.label}"` : ''; @@ -49,6 +144,7 @@ function importDashcam() { text += ` Errors: ${summary.errors.join('; ')}`; } importStatus.textContent = text; + refreshDashcamFiles(); }) .catch(err => { importStatus.textContent = 'Error while importing: ' + err; @@ -60,9 +156,35 @@ function importDashcam() { scanBtn.addEventListener('click', scanForDashcam); importBtn.addEventListener('click', importDashcam); +filesRefreshBtn.addEventListener('click', refreshDashcamFiles); +fileListBody.addEventListener('click', event => { + const deleteButton = event.target.closest('[data-delete-path]'); + if (deleteButton) { + deleteButton.disabled = true; + deleteDashcamFile(deleteButton.dataset.deletePath).finally(() => { + deleteButton.disabled = false; + }); + } +}); +deleteAllBtn.addEventListener('click', () => { + if (!window.confirm(deleteAllBtn.dataset.confirm)) { + return; + } + deleteAllBtn.disabled = true; + fetchJson('/api/dash/files', { method: 'DELETE' }) + .then(result => { + fileSummary.textContent = `Deleted ${result.deleted_count} video(s). Refreshing...`; + return refreshDashcamFiles(); + }) + .catch(err => { + fileSummary.textContent = `Delete all failed: ${err.message}`; + deleteAllBtn.disabled = false; + }); +}); // Auto-scan once when the page loads. scanForDashcam(); +refreshDashcamFiles(); // --- Remote server upload status/toggle --- diff --git a/templates/dash.html b/templates/dash.html index 62a00b6..149bb09 100644 --- a/templates/dash.html +++ b/templates/dash.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% from "components.html" import data_table %} {% block title %}Dashcam - Yaris OBD{% endblock %} @@ -14,6 +15,36 @@ +
+
+
+

Files on dashcam

+ Scanning... +
+
+ + +
+
+
+ {{ data_table( + "dashcam-file-list", + [ + {"key": "path", "label": "File", "sortable": true}, + {"key": "size", "label": "Size", "sortable": true}, + {"key": "modified", "label": "Modified", "sortable": true}, + {"key": "actions", "label": "Actions", "sortable": false} + ], + "No videos found", + "Connect the dashcam in USB storage mode, then refresh this list." + ) }} +
+

Import results