Add dashcam USB file browser
Co-authored-by: Copilot <[email protected]>
This commit is contained in:
@@ -38,6 +38,29 @@ def dash_import():
|
|||||||
summary = app_dash.import_dashcam()
|
summary = app_dash.import_dashcam()
|
||||||
return jsonify(summary)
|
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')
|
@app.route('/api/upload/status')
|
||||||
def upload_status():
|
def upload_status():
|
||||||
return jsonify(app_upload.get_status())
|
return jsonify(app_upload.get_status())
|
||||||
|
|||||||
+204
-15
@@ -20,6 +20,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import threading
|
||||||
|
|
||||||
VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".ts", ".mkv"}
|
VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".ts", ".mkv"}
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ DEFAULT_MOUNT_POINT = "/mnt/dashcam"
|
|||||||
DEFAULT_DESTINATION = os.path.join(
|
DEFAULT_DESTINATION = os.path.join(
|
||||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "dash", "videos"
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "dash", "videos"
|
||||||
)
|
)
|
||||||
|
_device_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def list_usb_storage_partitions():
|
def list_usb_storage_partitions():
|
||||||
@@ -107,8 +109,8 @@ def find_dashcam_partition():
|
|||||||
return (recognized or partitions)[0]
|
return (recognized or partitions)[0]
|
||||||
|
|
||||||
|
|
||||||
def mount_device(device_path, mount_point=DEFAULT_MOUNT_POINT):
|
def mount_device(device_path, mount_point=DEFAULT_MOUNT_POINT, read_only=True):
|
||||||
"""Mount device_path read-only at mount_point. Returns True on success.
|
"""Mount device_path at mount_point. Returns True on success.
|
||||||
|
|
||||||
The app runs as an unprivileged user while /mnt is root-owned, so the
|
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
|
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:
|
try:
|
||||||
subprocess.run(
|
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,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=True,
|
check=True,
|
||||||
@@ -139,6 +148,28 @@ def mount_device(device_path, mount_point=DEFAULT_MOUNT_POINT):
|
|||||||
return False
|
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):
|
def unmount_device(mount_point=DEFAULT_MOUNT_POINT):
|
||||||
"""Unmount mount_point. Returns True on success (or if already unmounted)."""
|
"""Unmount mount_point. Returns True on success (or if already unmounted)."""
|
||||||
try:
|
try:
|
||||||
@@ -170,6 +201,170 @@ def find_video_files(source_folder):
|
|||||||
return found
|
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):
|
def copy_files(source_folder, destination_folder):
|
||||||
"""Copy every video file found under source_folder into destination_folder,
|
"""Copy every video file found under source_folder into destination_folder,
|
||||||
preserving its relative sub-folder path (dashcams often split files into
|
preserving its relative sub-folder path (dashcams often split files into
|
||||||
@@ -210,29 +405,23 @@ def import_dashcam(destination_folder=DEFAULT_DESTINATION, mount_point=DEFAULT_M
|
|||||||
Returns a summary dict describing what happened, always unmounting the
|
Returns a summary dict describing what happened, always unmounting the
|
||||||
device again afterwards (even if copying fails partway through).
|
device again afterwards (even if copying fails partway through).
|
||||||
"""
|
"""
|
||||||
|
with _device_lock:
|
||||||
partition = find_dashcam_partition()
|
partition = find_dashcam_partition()
|
||||||
if partition is None:
|
if partition is None:
|
||||||
return {"status": "no_device", "message": "No USB dashcam/storage device detected."}
|
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(
|
||||||
# Already mounted elsewhere (e.g. by the desktop environment) - read
|
partition, mount_point, read_only=True
|
||||||
# straight from there instead of trying to mount it again ourselves.
|
)
|
||||||
already_mounted_at = partition.get("mountpoint")
|
if source is None:
|
||||||
|
|
||||||
mounted_here = False
|
|
||||||
try:
|
|
||||||
source = already_mounted_at
|
|
||||||
if not source:
|
|
||||||
if not mount_device(device_path, mount_point):
|
|
||||||
return {
|
return {
|
||||||
"status": "mount_failed",
|
"status": "mount_failed",
|
||||||
"device": device_path,
|
"device": device_path,
|
||||||
"message": f"Failed to mount {device_path}.",
|
"message": f"Failed to mount {device_path}.",
|
||||||
}
|
}
|
||||||
mounted_here = True
|
|
||||||
source = mount_point
|
|
||||||
|
|
||||||
|
try:
|
||||||
summary = copy_files(source, destination_folder)
|
summary = copy_files(source, destination_folder)
|
||||||
summary["status"] = "ok"
|
summary["status"] = "ok"
|
||||||
summary["device"] = device_path
|
summary["device"] = device_path
|
||||||
|
|||||||
@@ -7,6 +7,101 @@ const deviceStatus = document.getElementById('dash-device-status');
|
|||||||
const importStatus = document.getElementById('dash-import-status');
|
const importStatus = document.getElementById('dash-import-status');
|
||||||
const scanBtn = document.getElementById('dash-scan-btn');
|
const scanBtn = document.getElementById('dash-scan-btn');
|
||||||
const importBtn = document.getElementById('dash-import-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 = '<i class="fas fa-trash me-1"></i>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) {
|
function describeDevice(device) {
|
||||||
const label = device.label ? ` "${device.label}"` : '';
|
const label = device.label ? ` "${device.label}"` : '';
|
||||||
@@ -49,6 +144,7 @@ function importDashcam() {
|
|||||||
text += ` Errors: ${summary.errors.join('; ')}`;
|
text += ` Errors: ${summary.errors.join('; ')}`;
|
||||||
}
|
}
|
||||||
importStatus.textContent = text;
|
importStatus.textContent = text;
|
||||||
|
refreshDashcamFiles();
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
importStatus.textContent = 'Error while importing: ' + err;
|
importStatus.textContent = 'Error while importing: ' + err;
|
||||||
@@ -60,9 +156,35 @@ function importDashcam() {
|
|||||||
|
|
||||||
scanBtn.addEventListener('click', scanForDashcam);
|
scanBtn.addEventListener('click', scanForDashcam);
|
||||||
importBtn.addEventListener('click', importDashcam);
|
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.
|
// Auto-scan once when the page loads.
|
||||||
scanForDashcam();
|
scanForDashcam();
|
||||||
|
refreshDashcamFiles();
|
||||||
|
|
||||||
// --- Remote server upload status/toggle ---
|
// --- Remote server upload status/toggle ---
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% from "components.html" import data_table %}
|
||||||
|
|
||||||
{% block title %}Dashcam - Yaris OBD{% endblock %}
|
{% block title %}Dashcam - Yaris OBD{% endblock %}
|
||||||
|
|
||||||
@@ -14,6 +15,36 @@
|
|||||||
<button id="dash-import-btn" class="btn btn-primary" type="button" disabled>Import videos</button>
|
<button id="dash-import-btn" class="btn btn-primary" type="button" disabled>Import videos</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card shadow mb-4">
|
||||||
|
<div class="card-header py-3 d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p class="text-primary m-0 fw-bold">Files on dashcam</p>
|
||||||
|
<small id="dash-file-summary" class="text-muted">Scanning...</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button id="dash-files-refresh-btn" class="btn btn-sm btn-secondary" type="button">
|
||||||
|
<i class="fas fa-sync-alt me-1"></i>Refresh
|
||||||
|
</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>
|
||||||
|
<i class="fas fa-trash me-1"></i>Delete all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{{ 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."
|
||||||
|
) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="card shadow mb-4">
|
<div class="card shadow mb-4">
|
||||||
<div class="card-header py-3">
|
<div class="card-header py-3">
|
||||||
<p class="text-primary m-0 fw-bold">Import results</p>
|
<p class="text-primary m-0 fw-bold">Import results</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user