"""Dashcam USB retrieval. Detects a dashcam plugged in as a USB mass-storage device (no hardcoded /dev/sda, no brand-specific assumptions), mounts it read-only, copies any video files it finds (any folder layout - DCIM/, MOVIE/, NORM/EVENT/PARKING/, etc. - all common dashcam brands are covered since we just walk the whole filesystem for video extensions instead of assuming a specific folder name), then unmounts it again. Detection is done via `lsblk`, filtering for volumes whose parent disk is attached over USB (TRAN == "usb"). This is what actually distinguishes a plugged-in dashcam/USB stick from the Pi's own storage (the boot/root partitions are on the onboard SD card, transport "mmc", never "usb") - so it's safe to run against real hardware without risk of touching the Pi's own filesystem, regardless of which /dev/sdX letter the kernel happens to assign. Both real partitions and whole-disk "superfloppy" (no partition table) layouts are matched - some dashcam SD cards ship factory-formatted that way. """ import json import os import shutil import subprocess import threading VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".ts", ".mkv"} # Filesystem types dashcams commonly ship formatted as (SD cards are almost # always FAT32/exFAT; a few older units use NTFS). DASHCAM_FS_TYPES = {"vfat", "exfat", "ntfs", "ntfs3"} 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() _local_video_state_lock = threading.Lock() _local_videos_in_use = {} _local_videos_deleting = set() def begin_local_video_use(relative_path): with _local_video_state_lock: if relative_path in _local_videos_deleting: return False _local_videos_in_use[relative_path] = ( _local_videos_in_use.get(relative_path, 0) + 1 ) return True def end_local_video_use(relative_path): with _local_video_state_lock: uses = _local_videos_in_use.get(relative_path, 0) if uses <= 1: _local_videos_in_use.pop(relative_path, None) else: _local_videos_in_use[relative_path] = uses - 1 def begin_local_video_delete(relative_path): with _local_video_state_lock: if ( relative_path in _local_videos_in_use or relative_path in _local_videos_deleting ): return False _local_videos_deleting.add(relative_path) return True def end_local_video_delete(relative_path): with _local_video_state_lock: _local_videos_deleting.discard(relative_path) def list_usb_storage_partitions(): """Return a list of dicts describing USB-attached storage volumes. Each dict has: path, fstype, label, size, mountpoint. Only volumes whose parent disk reports TRAN "usb" are included - this is what makes the detection brand/device-agnostic (no hardcoded /dev/sda) while still being safe (never matches the Pi's own onboard mmc storage). Includes both real partitions (type "part", the common case) *and* whole-disk USB devices with an `fstype` set directly on them (type "disk" but no partition table - a "superfloppy" layout with no MBR/GPT). Several real dashcam SD cards ship formatted this way straight from the factory - lsblk reports them as a bare "disk" with an fstype, never as a child "part", so excluding them would silently miss those cameras. """ try: result = subprocess.run( ["lsblk", "-J", "-o", "NAME,PATH,TRAN,FSTYPE,LABEL,SIZE,MOUNTPOINT,TYPE"], capture_output=True, text=True, check=True, ) except (subprocess.CalledProcessError, FileNotFoundError) as e: print("Failed to run lsblk:", e) return [] try: data = json.loads(result.stdout) except json.JSONDecodeError as e: print("Failed to parse lsblk output:", e) return [] partitions = [] def walk(devices, inherited_tran=None): for device in devices: tran = device.get("tran") or inherited_tran children = device.get("children") or [] is_partition = device.get("type") == "part" is_superfloppy_disk = ( device.get("type") == "disk" and not children and device.get("fstype") ) if tran == "usb" and (is_partition or is_superfloppy_disk): partitions.append( { "path": device.get("path"), "fstype": device.get("fstype"), "label": device.get("label"), "size": device.get("size"), "mountpoint": device.get("mountpoint"), } ) if children: walk(children, tran) walk(data.get("blockdevices", [])) return partitions def find_dashcam_partition(): """Pick the best candidate USB partition to import from, or None. Prefers partitions with a recognized dashcam filesystem type; if several are found, the first one is returned (a single dashcam SD card should only expose one data partition). """ partitions = list_usb_storage_partitions() if not partitions: return None recognized = [p for p in partitions if p["fstype"] in DASHCAM_FS_TYPES] return (recognized or partitions)[0] 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 the mount call) - first real-hardware test surfaced this as a plain PermissionError from a bare os.makedirs(). """ try: subprocess.run( ["sudo", "mkdir", "-p", mount_point], capture_output=True, text=True, check=True, ) except subprocess.CalledProcessError as e: print(f"Failed to create mount point {mount_point}: {e.stderr.strip()}") return False try: subprocess.run( [ "sudo", "mount", "-o", "ro" if read_only else "rw", device_path, mount_point, ], capture_output=True, text=True, check=True, ) return True except subprocess.CalledProcessError as e: print(f"Failed to mount {device_path}: {e.stderr.strip()}") 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: subprocess.run( ["sudo", "umount", mount_point], capture_output=True, text=True, check=True, ) return True except subprocess.CalledProcessError as e: # Not mounted is fine - nothing to clean up. if "not mounted" in e.stderr.lower(): return True print(f"Failed to unmount {mount_point}: {e.stderr.strip()}") return False def find_video_files(source_folder): """Walk source_folder, returning (absolute_path, relative_path) for every file whose extension looks like a video, regardless of folder layout.""" found = [] for root, _dirs, files in os.walk(source_folder): for file in files: if os.path.splitext(file)[1].lower() in VIDEO_EXTENSIONS: abs_path = os.path.join(root, file) rel_path = os.path.relpath(abs_path, source_folder) found.append((abs_path, rel_path)) 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 dashcam_storage_usage(mount_point=DEFAULT_MOUNT_POINT): with _device_lock: partition = find_dashcam_partition() if partition is None: return {"status": "no_device"} source, mounted_here, _remounted = _mounted_source( partition, mount_point, read_only=True ) if source is None: return { "status": "mount_failed", "path": partition["path"], } try: usage = shutil.disk_usage(source) used_percent = round( usage.used / (usage.used + usage.free) * 100, 1 ) result = { "status": "ok", "path": partition["path"], "total_bytes": usage.total, "used_bytes": usage.used, "free_bytes": usage.free, "used_percent": used_percent, } except OSError as error: result = { "status": "read_failed", "path": partition["path"], "error": str(error), } finally: if mounted_here and not unmount_device(mount_point): result = { "status": "unmount_failed", "path": partition["path"], } return result 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 _remove_video_file(path): try: os.remove(path) return except PermissionError: pass try: subprocess.run( ["sudo", "rm", "--", path], capture_output=True, text=True, check=True, ) except subprocess.CalledProcessError as e: raise OSError(e.stderr.strip() or f"Failed to delete {path}") from e 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) _remove_video_file(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, 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. 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)) copied = 0 skipped = 0 for abs_path, rel_path in videos: dest_path = os.path.join(destination_folder, rel_path) if os.path.exists(dest_path) and os.path.getsize(dest_path) == os.path.getsize(abs_path): skipped += 1 continue os.makedirs(os.path.dirname(dest_path), exist_ok=True) try: shutil.copy2(abs_path, dest_path) copied += 1 # Queue background conversion if enabled (import app_video lazily to avoid circular import) from modules import app_video as _app_video _app_video.queue_convert(rel_path) except OSError as e: errors.append(f"{rel_path}: {e}") return {"found": len(videos), "copied": copied, "skipped": skipped, "errors": errors} 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 device again afterwards (even if copying fails partway through). """ 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"] 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}.", } try: 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: if mounted_here: unmount_device(mount_point) if __name__ == "__main__": print(import_dashcam())