Add local dashcam video library
Co-authored-by: Copilot <[email protected]>
This commit is contained in:
@@ -26,6 +26,7 @@ db/sql/
|
|||||||
|
|
||||||
# Imported dashcam footage (large, per-device data, not source)
|
# Imported dashcam footage (large, per-device data, not source)
|
||||||
dash/videos/
|
dash/videos/
|
||||||
|
dash/thumbnails/
|
||||||
|
|
||||||
# Remote server: local secrets and runtime data (Postgres volume, uploaded
|
# Remote server: local secrets and runtime data (Postgres volume, uploaded
|
||||||
# dashcam files) - never commit real credentials or per-device data
|
# dashcam files) - never commit real credentials or per-device data
|
||||||
|
|||||||
@@ -868,7 +868,7 @@ Phase 15 shared list component:
|
|||||||
original 87 files/29,615,111,758 bytes unmounted. The refined UI was then re-tested against a
|
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.
|
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 — ✅ DONE
|
||||||
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
|
||||||
(`dash/videos/`, populated by `import_dashcam()`), built on the Phase 15 shared list component:
|
(`dash/videos/`, populated by `import_dashcam()`), built on the Phase 15 shared list component:
|
||||||
- List endpoint (reuses `find_video_files()` against `app_dash.DEFAULT_DESTINATION`) showing
|
- List endpoint (reuses `find_video_files()` against `app_dash.DEFAULT_DESTINATION`) showing
|
||||||
@@ -895,6 +895,36 @@ Same file-browser treatment for videos already imported to the Pi's own disk
|
|||||||
Settings page - e.g. "delete local copies older than N days once uploaded", off by default)
|
Settings page - e.g. "delete local copies older than N days once uploaded", off by default)
|
||||||
so the Pi's limited storage doesn't need to be babysat manually as the dashcam backlog grows.
|
so the Pi's limited storage doesn't need to be babysat manually as the dashcam backlog grows.
|
||||||
|
|
||||||
|
**Implemented and real-hardware verified 2026-08-09**:
|
||||||
|
- Added local-video list, playback, thumbnail, and local-only delete APIs. Listing reports path,
|
||||||
|
size, modified time, and exact upload status without rehashing: one SQLite join matches the
|
||||||
|
existing `(relative path, size, mtime) -> SHA256` cache against confirmed uploads.
|
||||||
|
- Added a second `/dash` browser using the Phase 15 table component, with lazy thumbnails, sortable
|
||||||
|
file/size/time/upload columns, Uploaded/Pending badges, inline HTML5 video playback, confirmed
|
||||||
|
"Delete local" actions, totals, and retention status. Remote copies are never deleted.
|
||||||
|
- Playback uses Flask conditional file serving; a real `Range: bytes=0-1023` request returned 206,
|
||||||
|
the exact `Content-Range`, and 1,024 bytes. Browser-tested a real H.264 1080p MOV at mobile width:
|
||||||
|
the player reached readyState 4 with its duration loaded and no media error.
|
||||||
|
- Added cached ffmpeg JPEG thumbnails plus `deploy/install_video_tools.sh`; installed ffmpeg 5.1.9
|
||||||
|
on the Pi. Real clips exposed and fixed the edge case where ffmpeg exits 0 without output at a
|
||||||
|
late seek, so generation now retries frame zero and verifies a non-empty file. A normal 366MB
|
||||||
|
clip generated in ~4.1s and its cached request returned in ~81ms. Generation uses a cooperative
|
||||||
|
gevent semaphore/poll loop so cold thumbnails do not freeze the web server.
|
||||||
|
- Added off-by-default `local_video_retention_days` support. The upload loop checks it after sync;
|
||||||
|
only files older than N days whose exact current content is confirmed uploaded are eligible.
|
||||||
|
The Phase 21 Settings page will expose the control; `0`/unset remains disabled. Temporary
|
||||||
|
filesystem tests confirmed disabled retention deletes nothing and enabled retention preserves
|
||||||
|
pending files.
|
||||||
|
- Replaced a long-held upload/delete lock with per-path in-use/deleting state and reference counts:
|
||||||
|
delete now returns HTTP 409 while that exact file is uploading or generating a thumbnail instead
|
||||||
|
of blocking gevent and freezing every request.
|
||||||
|
- Deployed uncommitted code before committing and tested against the real 87-file/28GB Pi library:
|
||||||
|
all 87 reported uploaded, total bytes matched 29,615,111,758, and retention reported off. Copied
|
||||||
|
a tiny real clip to `YARIS_OBD_LOCAL_TEST.MOV`, verified it appeared Pending, played it in the
|
||||||
|
browser, and deleted it through the confirmed UI action; the player closed and the library
|
||||||
|
returned to exactly 87 uploaded files. Background uploads were paused for the disposable test,
|
||||||
|
restored afterward, and the service remained active.
|
||||||
|
|
||||||
### Phase 18: Trip history & replay — 🚧 PLANNED
|
### Phase 18: Trip history & replay — 🚧 PLANNED
|
||||||
**New phase.** Right now the `tracking` table (Phase 7) is just a raw stream of GPS/speed points
|
**New phase.** Right now the `tracking` table (Phase 7) is just a raw stream of GPS/speed points
|
||||||
with no concept of a discrete "trip" - there's no way to look back and see "trip #47, left home
|
with no concept of a discrete "trip" - there's no way to look back and see "trip #47, left home
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from flask import Flask, render_template, make_response, json, jsonify, request
|
from flask import Flask, render_template, make_response, json, jsonify, request, send_file
|
||||||
from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd, app_sql, app_dash, app_upload
|
from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd, app_sql, app_dash, app_upload, app_video
|
||||||
import time
|
import os, time
|
||||||
from gevent.pywsgi import WSGIServer
|
from gevent.pywsgi import WSGIServer
|
||||||
|
|
||||||
|
|
||||||
@@ -75,6 +75,52 @@ def dash_delete_file():
|
|||||||
}
|
}
|
||||||
return jsonify(summary), status_codes.get(summary["status"], 409)
|
return jsonify(summary), status_codes.get(summary["status"], 409)
|
||||||
|
|
||||||
|
@app.route('/api/local-videos')
|
||||||
|
def local_videos():
|
||||||
|
return jsonify(app_video.list_local_videos())
|
||||||
|
|
||||||
|
@app.route('/api/local-video/file')
|
||||||
|
def local_video_file():
|
||||||
|
relative_path = request.args.get("path", "")
|
||||||
|
try:
|
||||||
|
path = app_video.get_local_video_path(relative_path)
|
||||||
|
except ValueError as error:
|
||||||
|
return jsonify(error=str(error)), 400
|
||||||
|
except FileNotFoundError:
|
||||||
|
return jsonify(error=f"Local video not found: {relative_path}"), 404
|
||||||
|
return send_file(
|
||||||
|
path,
|
||||||
|
conditional=True,
|
||||||
|
as_attachment=False,
|
||||||
|
download_name=os.path.basename(path),
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route('/api/local-video/thumbnail')
|
||||||
|
def local_video_thumbnail():
|
||||||
|
relative_path = request.args.get("path", "")
|
||||||
|
try:
|
||||||
|
path = app_video.get_thumbnail_path(relative_path)
|
||||||
|
except ValueError as error:
|
||||||
|
return jsonify(error=str(error)), 400
|
||||||
|
except FileNotFoundError:
|
||||||
|
return jsonify(error=f"Local video not found: {relative_path}"), 404
|
||||||
|
except RuntimeError as error:
|
||||||
|
return jsonify(error=str(error)), 422
|
||||||
|
return send_file(path, conditional=True, mimetype="image/jpeg")
|
||||||
|
|
||||||
|
@app.route('/api/local-video', methods=['DELETE'])
|
||||||
|
def local_video_delete():
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
summary = app_video.delete_local_video(body.get("path"))
|
||||||
|
status_codes = {
|
||||||
|
"ok": 200,
|
||||||
|
"invalid_path": 400,
|
||||||
|
"not_found": 404,
|
||||||
|
"busy": 409,
|
||||||
|
"delete_failed": 409,
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
|||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install ffmpeg/ffprobe for local dashcam thumbnails and media diagnostics.
|
||||||
|
# Run this ON THE PI as the `yaris` user:
|
||||||
|
# cd /home/yaris/app/yaris-obd && bash deploy/install_video_tools.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y ffmpeg
|
||||||
|
|
||||||
|
ffmpeg -version | head -n 1
|
||||||
|
ffprobe -version | head -n 1
|
||||||
+42
-4
@@ -33,6 +33,44 @@ 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()
|
_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():
|
def list_usb_storage_partitions():
|
||||||
@@ -275,7 +313,7 @@ def list_dashcam_files(mount_point=DEFAULT_MOUNT_POINT):
|
|||||||
unmount_device(mount_point)
|
unmount_device(mount_point)
|
||||||
|
|
||||||
|
|
||||||
def _safe_video_path(source_folder, relative_path):
|
def safe_video_path(source_folder, relative_path):
|
||||||
if not isinstance(relative_path, str) or not relative_path.strip():
|
if not isinstance(relative_path, str) or not relative_path.strip():
|
||||||
raise ValueError("A non-empty relative video path is required.")
|
raise ValueError("A non-empty relative video path is required.")
|
||||||
if os.path.isabs(relative_path):
|
if os.path.isabs(relative_path):
|
||||||
@@ -337,7 +375,7 @@ def delete_dashcam_files(relative_path=None, delete_all=False, mount_point=DEFAU
|
|||||||
targets = find_video_files(source)
|
targets = find_video_files(source)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
target = _safe_video_path(source, relative_path)
|
target = safe_video_path(source, relative_path)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
result = {"status": "invalid_path", "message": str(e)}
|
result = {"status": "invalid_path", "message": str(e)}
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -353,7 +391,7 @@ def delete_dashcam_files(relative_path=None, delete_all=False, mount_point=DEFAU
|
|||||||
errors = []
|
errors = []
|
||||||
for _abs_path, rel_path in targets:
|
for _abs_path, rel_path in targets:
|
||||||
try:
|
try:
|
||||||
safe_path = _safe_video_path(source, rel_path)
|
safe_path = safe_video_path(source, rel_path)
|
||||||
_remove_video_file(safe_path)
|
_remove_video_file(safe_path)
|
||||||
deleted.append(rel_path)
|
deleted.append(rel_path)
|
||||||
except (OSError, ValueError) as e:
|
except (OSError, ValueError) as e:
|
||||||
@@ -413,7 +451,7 @@ def copy_files(source_folder, destination_folder, relative_paths=None):
|
|||||||
continue
|
continue
|
||||||
seen.add(relative_path)
|
seen.add(relative_path)
|
||||||
try:
|
try:
|
||||||
abs_path = _safe_video_path(source_folder, relative_path)
|
abs_path = safe_video_path(source_folder, relative_path)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return {
|
return {
|
||||||
"found": 0,
|
"found": 0,
|
||||||
|
|||||||
@@ -222,6 +222,46 @@ def dashcam_file_marked_uploaded(relative_path, sha256):
|
|||||||
return cur.fetchone() is not None
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def dashcam_file_uploaded_for_stat(relative_path, size, mtime):
|
||||||
|
"""Whether the current local file version is confirmed uploaded.
|
||||||
|
|
||||||
|
Uses the existing hash cache so list/retention scans never rehash large
|
||||||
|
video files merely to display their status.
|
||||||
|
"""
|
||||||
|
conn = get_conn()
|
||||||
|
if conn is None:
|
||||||
|
return False
|
||||||
|
with _conn_lock:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""SELECT 1
|
||||||
|
FROM dashcam_hash_cache AS cache
|
||||||
|
JOIN dashcam_uploads AS uploads
|
||||||
|
ON uploads.RELATIVE_PATH = cache.RELATIVE_PATH
|
||||||
|
AND uploads.SHA256 = cache.SHA256
|
||||||
|
WHERE cache.RELATIVE_PATH = ?
|
||||||
|
AND cache.SIZE = ?
|
||||||
|
AND cache.MTIME = ?""",
|
||||||
|
(relative_path, size, mtime),
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def uploaded_dashcam_file_stats():
|
||||||
|
"""Return exact uploaded file versions as (path, size, mtime) tuples."""
|
||||||
|
conn = get_conn()
|
||||||
|
if conn is None:
|
||||||
|
return set()
|
||||||
|
with _conn_lock:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""SELECT cache.RELATIVE_PATH, cache.SIZE, cache.MTIME
|
||||||
|
FROM dashcam_hash_cache AS cache
|
||||||
|
JOIN dashcam_uploads AS uploads
|
||||||
|
ON uploads.RELATIVE_PATH = cache.RELATIVE_PATH
|
||||||
|
AND uploads.SHA256 = cache.SHA256"""
|
||||||
|
)
|
||||||
|
return set(cur.fetchall())
|
||||||
|
|
||||||
|
|
||||||
def mark_dashcam_file_uploaded(relative_path, sha256):
|
def mark_dashcam_file_uploaded(relative_path, sha256):
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
if conn is None:
|
if conn is None:
|
||||||
|
|||||||
+20
-6
@@ -43,7 +43,7 @@ import requests
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from requests_toolbelt import MultipartEncoder
|
from requests_toolbelt import MultipartEncoder
|
||||||
|
|
||||||
from modules import app_sql, app_dash, app_cellular
|
from modules import app_sql, app_dash, app_cellular, app_video
|
||||||
|
|
||||||
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
load_dotenv(os.path.join(_BASE_DIR, "db", "upload_creds.env"))
|
load_dotenv(os.path.join(_BASE_DIR, "db", "upload_creds.env"))
|
||||||
@@ -253,13 +253,20 @@ def _upload_dashcam_files():
|
|||||||
|
|
||||||
uploaded_count = 0
|
uploaded_count = 0
|
||||||
for abs_path, rel_path in app_dash.find_video_files(app_dash.DEFAULT_DESTINATION):
|
for abs_path, rel_path in app_dash.find_video_files(app_dash.DEFAULT_DESTINATION):
|
||||||
sha256 = _hash_dashcam_file(abs_path, rel_path)
|
if not app_dash.begin_local_video_use(rel_path):
|
||||||
if app_sql.dashcam_file_marked_uploaded(rel_path, sha256):
|
|
||||||
continue
|
continue
|
||||||
|
try:
|
||||||
|
if not os.path.isfile(abs_path):
|
||||||
|
continue
|
||||||
|
sha256 = _hash_dashcam_file(abs_path, rel_path)
|
||||||
|
if app_sql.dashcam_file_marked_uploaded(rel_path, sha256):
|
||||||
|
continue
|
||||||
|
|
||||||
_upload_video_chunked(abs_path, rel_path, sha256)
|
_upload_video_chunked(abs_path, rel_path, sha256)
|
||||||
app_sql.mark_dashcam_file_uploaded(rel_path, sha256)
|
app_sql.mark_dashcam_file_uploaded(rel_path, sha256)
|
||||||
uploaded_count += 1
|
uploaded_count += 1
|
||||||
|
finally:
|
||||||
|
app_dash.end_local_video_use(rel_path)
|
||||||
|
|
||||||
# Re-check between files so flipping the toggle off mid-run stops
|
# Re-check between files so flipping the toggle off mid-run stops
|
||||||
# further uploads promptly without waiting for the next cycle.
|
# further uploads promptly without waiting for the next cycle.
|
||||||
@@ -281,8 +288,15 @@ def _upload_loop():
|
|||||||
try:
|
try:
|
||||||
sent = _upload_tracking_batch()
|
sent = _upload_tracking_batch()
|
||||||
uploaded = _upload_dashcam_files()
|
uploaded = _upload_dashcam_files()
|
||||||
|
cleanup = app_video.cleanup_uploaded_videos()
|
||||||
if sent or uploaded:
|
if sent or uploaded:
|
||||||
print(f"Upload: sent {sent} tracking rows, {uploaded} dashcam files")
|
print(f"Upload: sent {sent} tracking rows, {uploaded} dashcam files")
|
||||||
|
if cleanup["deleted"]:
|
||||||
|
print(
|
||||||
|
f"Retention: deleted {len(cleanup['deleted'])} uploaded local videos"
|
||||||
|
)
|
||||||
|
if cleanup["errors"]:
|
||||||
|
print("Retention cleanup errors:", "; ".join(cleanup["errors"]))
|
||||||
_last_error = None
|
_last_error = None
|
||||||
_last_success_at = _dt.datetime.now().isoformat()
|
_last_success_at = _dt.datetime.now().isoformat()
|
||||||
backoff = BACKOFF_INITIAL
|
backoff = BACKOFF_INITIAL
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""Local imported-video browsing, playback, thumbnails, and retention."""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
from gevent import sleep
|
||||||
|
from gevent.lock import Semaphore
|
||||||
|
from modules import app_dash, app_sql
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
THUMBNAIL_DIR = os.path.join(BASE_DIR, "dash", "thumbnails")
|
||||||
|
RETENTION_SETTING = "local_video_retention_days"
|
||||||
|
_thumbnail_lock = Semaphore()
|
||||||
|
|
||||||
|
|
||||||
|
def _local_path(relative_path):
|
||||||
|
return app_dash.safe_video_path(app_dash.DEFAULT_DESTINATION, relative_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _thumbnail_path(relative_path):
|
||||||
|
source_root = os.path.realpath(app_dash.DEFAULT_DESTINATION)
|
||||||
|
source_path = app_dash.safe_video_path(source_root, relative_path)
|
||||||
|
thumbnail_root = os.path.realpath(THUMBNAIL_DIR)
|
||||||
|
thumbnail_path = os.path.realpath(
|
||||||
|
os.path.join(thumbnail_root, relative_path + ".jpg")
|
||||||
|
)
|
||||||
|
if os.path.commonpath([thumbnail_root, thumbnail_path]) != thumbnail_root:
|
||||||
|
raise ValueError("Thumbnail path escapes the cache directory.")
|
||||||
|
return source_path, thumbnail_path
|
||||||
|
|
||||||
|
|
||||||
|
def list_local_videos():
|
||||||
|
files = []
|
||||||
|
errors = []
|
||||||
|
total_size = 0
|
||||||
|
uploaded_stats = app_sql.uploaded_dashcam_file_stats()
|
||||||
|
for abs_path, relative_path in app_dash.find_video_files(
|
||||||
|
app_dash.DEFAULT_DESTINATION
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
stat = os.stat(abs_path)
|
||||||
|
except OSError as e:
|
||||||
|
errors.append(f"{relative_path}: {e}")
|
||||||
|
continue
|
||||||
|
files.append(
|
||||||
|
{
|
||||||
|
"path": relative_path,
|
||||||
|
"size": stat.st_size,
|
||||||
|
"modified_at": stat.st_mtime,
|
||||||
|
"uploaded": (
|
||||||
|
relative_path,
|
||||||
|
stat.st_size,
|
||||||
|
stat.st_mtime,
|
||||||
|
) in uploaded_stats,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
total_size += stat.st_size
|
||||||
|
files.sort(key=lambda item: item["path"].lower())
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"files": files,
|
||||||
|
"count": len(files),
|
||||||
|
"total_size": total_size,
|
||||||
|
"errors": errors,
|
||||||
|
"retention_days": retention_days(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_local_video_path(relative_path):
|
||||||
|
return _local_path(relative_path)
|
||||||
|
|
||||||
|
|
||||||
|
def get_thumbnail_path(relative_path):
|
||||||
|
source_path, thumbnail_path = _thumbnail_path(relative_path)
|
||||||
|
source_mtime = os.path.getmtime(source_path)
|
||||||
|
|
||||||
|
with _thumbnail_lock:
|
||||||
|
if (
|
||||||
|
os.path.isfile(thumbnail_path)
|
||||||
|
and os.path.getmtime(thumbnail_path) >= source_mtime
|
||||||
|
):
|
||||||
|
return thumbnail_path
|
||||||
|
if not app_dash.begin_local_video_use(relative_path):
|
||||||
|
raise RuntimeError(
|
||||||
|
"This video is currently being deleted. Try the thumbnail again shortly."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(thumbnail_path), exist_ok=True)
|
||||||
|
temporary_path = thumbnail_path + ".tmp.jpg"
|
||||||
|
error = None
|
||||||
|
for seek_seconds in ("1", "0"):
|
||||||
|
command = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-nostdin",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-ss",
|
||||||
|
seek_seconds,
|
||||||
|
"-i",
|
||||||
|
source_path,
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
"-vf",
|
||||||
|
"scale=320:-2",
|
||||||
|
"-q:v",
|
||||||
|
"5",
|
||||||
|
"-y",
|
||||||
|
temporary_path,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
command,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise RuntimeError("ffmpeg is not installed on this device.") from e
|
||||||
|
while process.poll() is None:
|
||||||
|
sleep(0.05)
|
||||||
|
_stdout, error = process.communicate()
|
||||||
|
if (
|
||||||
|
process.returncode == 0
|
||||||
|
and os.path.isfile(temporary_path)
|
||||||
|
and os.path.getsize(temporary_path) > 0
|
||||||
|
):
|
||||||
|
os.replace(temporary_path, thumbnail_path)
|
||||||
|
return thumbnail_path
|
||||||
|
if os.path.exists(temporary_path):
|
||||||
|
os.remove(temporary_path)
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
(error or "").strip()
|
||||||
|
or f"ffmpeg could not extract a frame from {relative_path}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
app_dash.end_local_video_use(relative_path)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_local_video(relative_path):
|
||||||
|
try:
|
||||||
|
video_path, thumbnail_path = _thumbnail_path(relative_path)
|
||||||
|
except ValueError as e:
|
||||||
|
return {"status": "invalid_path", "message": str(e)}
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {
|
||||||
|
"status": "not_found",
|
||||||
|
"message": f"Local video not found: {relative_path}",
|
||||||
|
}
|
||||||
|
if not app_dash.begin_local_video_delete(relative_path):
|
||||||
|
return {
|
||||||
|
"status": "busy",
|
||||||
|
"message": "This video is currently uploading or being deleted. Try again shortly.",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
os.remove(video_path)
|
||||||
|
if os.path.isfile(thumbnail_path):
|
||||||
|
os.remove(thumbnail_path)
|
||||||
|
except OSError as e:
|
||||||
|
return {"status": "delete_failed", "message": str(e)}
|
||||||
|
finally:
|
||||||
|
app_dash.end_local_video_delete(relative_path)
|
||||||
|
return {"status": "ok", "deleted": relative_path}
|
||||||
|
|
||||||
|
|
||||||
|
def retention_days():
|
||||||
|
value = app_sql.get_setting(RETENTION_SETTING, "0")
|
||||||
|
try:
|
||||||
|
days = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
print(f"Invalid {RETENTION_SETTING} setting {value!r}; retention disabled.")
|
||||||
|
return 0
|
||||||
|
return max(days, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_uploaded_videos(now=None):
|
||||||
|
"""Delete uploaded local copies older than the configured number of days."""
|
||||||
|
days = retention_days()
|
||||||
|
if days <= 0:
|
||||||
|
return {"enabled": False, "deleted": [], "errors": []}
|
||||||
|
|
||||||
|
cutoff = (time.time() if now is None else now) - days * 86400
|
||||||
|
deleted = []
|
||||||
|
errors = []
|
||||||
|
uploaded_stats = app_sql.uploaded_dashcam_file_stats()
|
||||||
|
for abs_path, relative_path in app_dash.find_video_files(
|
||||||
|
app_dash.DEFAULT_DESTINATION
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
stat = os.stat(abs_path)
|
||||||
|
if stat.st_mtime >= cutoff:
|
||||||
|
continue
|
||||||
|
if (relative_path, stat.st_size, stat.st_mtime) not in uploaded_stats:
|
||||||
|
continue
|
||||||
|
if not app_dash.begin_local_video_delete(relative_path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_source, thumbnail_path = _thumbnail_path(relative_path)
|
||||||
|
os.remove(abs_path)
|
||||||
|
if os.path.isfile(thumbnail_path):
|
||||||
|
os.remove(thumbnail_path)
|
||||||
|
deleted.append(relative_path)
|
||||||
|
finally:
|
||||||
|
app_dash.end_local_video_delete(relative_path)
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
errors.append(f"{relative_path}: {e}")
|
||||||
|
return {"enabled": True, "deleted": deleted, "errors": errors}
|
||||||
@@ -45,6 +45,9 @@ i have a dream
|
|||||||
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
|
plus import one file or any checkbox selection instead of always importing the whole card
|
||||||
|
- ▶️ **Local video library** — browse imported clips with cached thumbnails and upload status,
|
||||||
|
play H.264 footage inline with seeking, and delete only the local Pi copy. Optional retention
|
||||||
|
can automatically remove older local copies after their exact content is confirmed uploaded
|
||||||
- ☁️ **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
|
||||||
@@ -89,7 +92,7 @@ For the remote server (receives trip logs + dashcam uploads), see [server/README
|
|||||||
|
|
||||||
## Project status
|
## Project status
|
||||||
|
|
||||||
This project was revived after a couple of years on the shelf. Phases 1-13 and 15-16 are complete:
|
This project was revived after a couple of years on the shelf. Phases 1-13 and 15-17 are complete:
|
||||||
real GNSS, hardened OBD support, offline maps/UI, automatic WiFi hotspot switching, local logging,
|
real GNSS, hardened OBD support, offline maps/UI, automatic WiFi hotspot switching, local logging,
|
||||||
dashcam import, resumable remote upload, deployment, and the shared UI foundation are implemented.
|
dashcam import, resumable remote upload, deployment, and the shared UI foundation are implemented.
|
||||||
The pipeline has been exercised on the real Pi and dashcam, including a complete 28GB import and
|
The pipeline has been exercised on the real Pi and dashcam, including a complete 28GB import and
|
||||||
@@ -101,6 +104,6 @@ partition, and clean shutdown still needs delayed-power or ignition-sense hardwa
|
|||||||
|
|
||||||
The browser-based phone relay was removed after real-world testing confirmed that mobile browsers
|
The browser-based phone relay was removed after real-world testing confirmed that mobile browsers
|
||||||
cannot reliably use the Pi's WiFi and cellular data simultaneously. A native Android companion is
|
cannot reliably use the Pi's WiFi and cellular data simultaneously. A native Android companion is
|
||||||
planned later instead. The next roadmap work is the local imported-video browser/player, followed by trip history, Pi
|
planned later instead. The next roadmap work is trip history, followed by Pi health, alerts, settings, and a final UI
|
||||||
health, alerts, settings, and a final UI polish pass. Full phase history, verification notes, and
|
polish pass. Full phase history, verification notes, and current scope live in
|
||||||
current scope live in [PLAN.md](./PLAN.md).
|
[PLAN.md](./PLAN.md).
|
||||||
|
|||||||
+2
-11
@@ -25,15 +25,6 @@ function fetchJson(url, options) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
function createCell(text) {
|
||||||
const cell = document.createElement('td');
|
const cell = document.createElement('td');
|
||||||
cell.textContent = text;
|
cell.textContent = text;
|
||||||
@@ -93,7 +84,7 @@ function renderDashcamFiles(summary) {
|
|||||||
row.appendChild(selection);
|
row.appendChild(selection);
|
||||||
|
|
||||||
row.appendChild(createCell(file.path));
|
row.appendChild(createCell(file.path));
|
||||||
row.appendChild(createCell(formatBytes(file.size)));
|
row.appendChild(createCell(YarisUI.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');
|
||||||
@@ -116,7 +107,7 @@ function renderDashcamFiles(summary) {
|
|||||||
fileListBody.appendChild(row);
|
fileListBody.appendChild(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
fileSummary.textContent = `${files.length} video(s), ${formatBytes(summary.total_size || 0)} total`;
|
fileSummary.textContent = `${files.length} video(s), ${YarisUI.formatBytes(summary.total_size || 0)} total`;
|
||||||
if (summary.errors && summary.errors.length) {
|
if (summary.errors && summary.errors.length) {
|
||||||
fileSummary.textContent += ` (${summary.errors.length} file error(s))`;
|
fileSummary.textContent += ` (${summary.errors.length} file error(s))`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
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 compareRows(left, right, key, direction) {
|
function compareRows(left, right, key, direction) {
|
||||||
const leftValue = left.dataset[key] || "";
|
const leftValue = left.dataset[key] || "";
|
||||||
const rightValue = right.dataset[key] || "";
|
const rightValue = right.dataset[key] || "";
|
||||||
@@ -66,4 +78,5 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll("[data-data-list]").forEach(initializeList);
|
document.querySelectorAll("[data-data-list]").forEach(initializeList);
|
||||||
|
window.YarisUI = Object.assign(window.YarisUI || {}, { formatBytes });
|
||||||
}());
|
}());
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const summary = document.getElementById("local-video-summary");
|
||||||
|
const body = document.querySelector("#local-video-list [data-data-list-body]");
|
||||||
|
const refreshButton = document.getElementById("local-videos-refresh-btn");
|
||||||
|
const playerPanel = document.getElementById("local-video-player-panel");
|
||||||
|
const playerTitle = document.getElementById("local-video-player-title");
|
||||||
|
const player = document.getElementById("local-video-player");
|
||||||
|
const closePlayerButton = document.getElementById("local-video-player-close");
|
||||||
|
let playingPath = null;
|
||||||
|
|
||||||
|
const thumbnailObserver = new IntersectionObserver(function (entries, observer) {
|
||||||
|
entries.forEach(function (entry) {
|
||||||
|
if (!entry.isIntersecting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const image = entry.target;
|
||||||
|
image.src = `/api/local-video/thumbnail?path=${encodeURIComponent(image.dataset.path)}`;
|
||||||
|
observer.unobserve(image);
|
||||||
|
});
|
||||||
|
}, { rootMargin: "200px" });
|
||||||
|
|
||||||
|
function fetchJson(url, options) {
|
||||||
|
return fetch(url, options).then(function (response) {
|
||||||
|
return response.json().then(function (data) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || data.error || data.status || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function textCell(text) {
|
||||||
|
const cell = document.createElement("td");
|
||||||
|
cell.textContent = text;
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePlayer() {
|
||||||
|
player.pause();
|
||||||
|
player.removeAttribute("src");
|
||||||
|
player.load();
|
||||||
|
playerPanel.hidden = true;
|
||||||
|
playingPath = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function playVideo(path) {
|
||||||
|
playingPath = path;
|
||||||
|
playerTitle.textContent = path;
|
||||||
|
player.src = `/api/local-video/file?path=${encodeURIComponent(path)}`;
|
||||||
|
playerPanel.hidden = false;
|
||||||
|
player.load();
|
||||||
|
player.play().catch(function () {
|
||||||
|
// Browser autoplay policy may require the user to press play.
|
||||||
|
});
|
||||||
|
playerPanel.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFiles(result) {
|
||||||
|
thumbnailObserver.disconnect();
|
||||||
|
body.replaceChildren();
|
||||||
|
const files = result.files || [];
|
||||||
|
let uploadedCount = 0;
|
||||||
|
|
||||||
|
files.forEach(function (file) {
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
row.dataset.path = file.path;
|
||||||
|
row.dataset.size = file.size;
|
||||||
|
row.dataset.modified = file.modified_at;
|
||||||
|
row.dataset.uploaded = file.uploaded ? "1" : "0";
|
||||||
|
|
||||||
|
const preview = document.createElement("td");
|
||||||
|
const image = document.createElement("img");
|
||||||
|
image.className = "local-video-thumbnail";
|
||||||
|
image.alt = `Preview of ${file.path}`;
|
||||||
|
image.loading = "lazy";
|
||||||
|
image.dataset.path = file.path;
|
||||||
|
image.addEventListener("error", function () {
|
||||||
|
image.hidden = true;
|
||||||
|
});
|
||||||
|
preview.appendChild(image);
|
||||||
|
row.appendChild(preview);
|
||||||
|
thumbnailObserver.observe(image);
|
||||||
|
|
||||||
|
row.appendChild(textCell(file.path));
|
||||||
|
row.appendChild(textCell(YarisUI.formatBytes(file.size)));
|
||||||
|
row.appendChild(textCell(new Date(file.modified_at * 1000).toLocaleString()));
|
||||||
|
|
||||||
|
const upload = document.createElement("td");
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = file.uploaded ? "badge bg-success" : "badge bg-warning text-dark";
|
||||||
|
badge.textContent = file.uploaded ? "Uploaded" : "Pending";
|
||||||
|
upload.appendChild(badge);
|
||||||
|
row.appendChild(upload);
|
||||||
|
if (file.uploaded) {
|
||||||
|
uploadedCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = document.createElement("td");
|
||||||
|
actions.className = "data-list-actions";
|
||||||
|
const playButton = document.createElement("button");
|
||||||
|
playButton.type = "button";
|
||||||
|
playButton.className = "btn btn-sm btn-outline-primary";
|
||||||
|
playButton.dataset.playPath = file.path;
|
||||||
|
playButton.innerHTML = '<i class="fas fa-play me-1"></i>Play';
|
||||||
|
actions.appendChild(playButton);
|
||||||
|
|
||||||
|
const deleteButton = document.createElement("button");
|
||||||
|
deleteButton.type = "button";
|
||||||
|
deleteButton.className = "btn btn-sm btn-outline-danger";
|
||||||
|
deleteButton.dataset.deleteLocalPath = file.path;
|
||||||
|
deleteButton.dataset.confirm = `Delete the local Pi copy of "${file.path}"? The remote archive will not be changed.`;
|
||||||
|
deleteButton.innerHTML = '<i class="fas fa-trash me-1"></i>Delete local';
|
||||||
|
actions.appendChild(deleteButton);
|
||||||
|
row.appendChild(actions);
|
||||||
|
body.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
summary.textContent = `${files.length} video(s), ${YarisUI.formatBytes(result.total_size || 0)} total, ${uploadedCount} uploaded`;
|
||||||
|
if (result.retention_days > 0) {
|
||||||
|
summary.textContent += `; uploaded copies older than ${result.retention_days} day(s) are auto-deleted`;
|
||||||
|
} else {
|
||||||
|
summary.textContent += "; automatic retention cleanup is off";
|
||||||
|
}
|
||||||
|
if (result.errors && result.errors.length) {
|
||||||
|
summary.textContent += ` (${result.errors.length} file error(s))`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshFiles() {
|
||||||
|
refreshButton.disabled = true;
|
||||||
|
summary.textContent = "Loading...";
|
||||||
|
return fetchJson("/api/local-videos")
|
||||||
|
.then(renderFiles)
|
||||||
|
.catch(function (error) {
|
||||||
|
body.replaceChildren();
|
||||||
|
summary.textContent = `Unable to list local videos: ${error.message}`;
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
refreshButton.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteVideo(path) {
|
||||||
|
return fetchJson("/api/local-video", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ path }),
|
||||||
|
}).then(function () {
|
||||||
|
if (playingPath === path) {
|
||||||
|
closePlayer();
|
||||||
|
}
|
||||||
|
return refreshFiles();
|
||||||
|
}).catch(function (error) {
|
||||||
|
summary.textContent = `Local delete failed: ${error.message}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
body.addEventListener("click", function (event) {
|
||||||
|
const playButton = event.target.closest("[data-play-path]");
|
||||||
|
if (playButton) {
|
||||||
|
playVideo(playButton.dataset.playPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteButton = event.target.closest("[data-delete-local-path]");
|
||||||
|
if (deleteButton) {
|
||||||
|
deleteButton.disabled = true;
|
||||||
|
deleteVideo(deleteButton.dataset.deleteLocalPath).finally(function () {
|
||||||
|
deleteButton.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
refreshButton.addEventListener("click", refreshFiles);
|
||||||
|
closePlayerButton.addEventListener("click", closePlayer);
|
||||||
|
refreshFiles();
|
||||||
|
}());
|
||||||
@@ -38,3 +38,19 @@
|
|||||||
gap: .5rem;
|
gap: .5rem;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.local-video-thumbnail {
|
||||||
|
width: 8rem;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
object-fit: cover;
|
||||||
|
color: transparent;
|
||||||
|
background: var(--bs-gray-200);
|
||||||
|
border-radius: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.local-video-player {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 70vh;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
@@ -60,6 +60,43 @@
|
|||||||
<p id="dash-import-status">No import has been run yet.</p>
|
<p id="dash-import-status">No import has been run yet.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="local-video-player-panel" class="card shadow mb-4" hidden>
|
||||||
|
<div class="card-header py-3 d-flex align-items-center justify-content-between">
|
||||||
|
<p id="local-video-player-title" class="text-primary m-0 fw-bold">Video player</p>
|
||||||
|
<button id="local-video-player-close" class="btn btn-sm btn-secondary" type="button">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<video id="local-video-player" class="local-video-player" controls preload="metadata"></video>
|
||||||
|
</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">Imported videos on Pi</p>
|
||||||
|
<small id="local-video-summary" class="text-muted">Loading...</small>
|
||||||
|
</div>
|
||||||
|
<button id="local-videos-refresh-btn" class="btn btn-sm btn-secondary" type="button">
|
||||||
|
<i class="fas fa-sync-alt me-1"></i>Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{{ data_table(
|
||||||
|
"local-video-list",
|
||||||
|
[
|
||||||
|
{"key": "thumbnail", "label": "Preview", "sortable": false},
|
||||||
|
{"key": "path", "label": "File", "sortable": true},
|
||||||
|
{"key": "size", "label": "Size", "sortable": true},
|
||||||
|
{"key": "modified", "label": "Modified", "sortable": true},
|
||||||
|
{"key": "uploaded", "label": "Upload", "sortable": true},
|
||||||
|
{"key": "actions", "label": "Actions", "sortable": false}
|
||||||
|
],
|
||||||
|
"No imported videos",
|
||||||
|
"Import videos from the connected dashcam to browse and play them here."
|
||||||
|
) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="card shadow">
|
<div class="card shadow">
|
||||||
<div class="card-header py-3">
|
<div class="card-header py-3">
|
||||||
<p class="text-primary m-0 fw-bold">Remote server upload</p>
|
<p class="text-primary m-0 fw-bold">Remote server upload</p>
|
||||||
@@ -85,4 +122,5 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ url_for('static', filename='js/dash.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/dash.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/local-videos.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user