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)
|
||||
dash/videos/
|
||||
dash/thumbnails/
|
||||
|
||||
# Remote server: local secrets and runtime data (Postgres volume, uploaded
|
||||
# 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
|
||||
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
|
||||
(`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
|
||||
@@ -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)
|
||||
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
|
||||
**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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from flask import Flask, render_template, make_response, json, jsonify, request
|
||||
from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd, app_sql, app_dash, app_upload
|
||||
import time
|
||||
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, app_video
|
||||
import os, time
|
||||
from gevent.pywsgi import WSGIServer
|
||||
|
||||
|
||||
@@ -75,6 +75,52 @@ def dash_delete_file():
|
||||
}
|
||||
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')
|
||||
def upload_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"
|
||||
)
|
||||
_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():
|
||||
@@ -275,7 +313,7 @@ def list_dashcam_files(mount_point=DEFAULT_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():
|
||||
raise ValueError("A non-empty relative video path is required.")
|
||||
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)
|
||||
else:
|
||||
try:
|
||||
target = _safe_video_path(source, relative_path)
|
||||
target = safe_video_path(source, relative_path)
|
||||
except ValueError as e:
|
||||
result = {"status": "invalid_path", "message": str(e)}
|
||||
except FileNotFoundError:
|
||||
@@ -353,7 +391,7 @@ def delete_dashcam_files(relative_path=None, delete_all=False, mount_point=DEFAU
|
||||
errors = []
|
||||
for _abs_path, rel_path in targets:
|
||||
try:
|
||||
safe_path = _safe_video_path(source, rel_path)
|
||||
safe_path = safe_video_path(source, rel_path)
|
||||
_remove_video_file(safe_path)
|
||||
deleted.append(rel_path)
|
||||
except (OSError, ValueError) as e:
|
||||
@@ -413,7 +451,7 @@ def copy_files(source_folder, destination_folder, relative_paths=None):
|
||||
continue
|
||||
seen.add(relative_path)
|
||||
try:
|
||||
abs_path = _safe_video_path(source_folder, relative_path)
|
||||
abs_path = safe_video_path(source_folder, relative_path)
|
||||
except ValueError as e:
|
||||
return {
|
||||
"found": 0,
|
||||
|
||||
@@ -222,6 +222,46 @@ def dashcam_file_marked_uploaded(relative_path, sha256):
|
||||
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):
|
||||
conn = get_conn()
|
||||
if conn is None:
|
||||
|
||||
+20
-6
@@ -43,7 +43,7 @@ import requests
|
||||
from dotenv import load_dotenv
|
||||
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__)))
|
||||
load_dotenv(os.path.join(_BASE_DIR, "db", "upload_creds.env"))
|
||||
@@ -253,13 +253,20 @@ def _upload_dashcam_files():
|
||||
|
||||
uploaded_count = 0
|
||||
for abs_path, rel_path in app_dash.find_video_files(app_dash.DEFAULT_DESTINATION):
|
||||
sha256 = _hash_dashcam_file(abs_path, rel_path)
|
||||
if app_sql.dashcam_file_marked_uploaded(rel_path, sha256):
|
||||
if not app_dash.begin_local_video_use(rel_path):
|
||||
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)
|
||||
app_sql.mark_dashcam_file_uploaded(rel_path, sha256)
|
||||
uploaded_count += 1
|
||||
_upload_video_chunked(abs_path, rel_path, sha256)
|
||||
app_sql.mark_dashcam_file_uploaded(rel_path, sha256)
|
||||
uploaded_count += 1
|
||||
finally:
|
||||
app_dash.end_local_video_use(rel_path)
|
||||
|
||||
# Re-check between files so flipping the toggle off mid-run stops
|
||||
# further uploads promptly without waiting for the next cycle.
|
||||
@@ -281,8 +288,15 @@ def _upload_loop():
|
||||
try:
|
||||
sent = _upload_tracking_batch()
|
||||
uploaded = _upload_dashcam_files()
|
||||
cleanup = app_video.cleanup_uploaded_videos()
|
||||
if sent or uploaded:
|
||||
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_success_at = _dt.datetime.now().isoformat()
|
||||
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
|
||||
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
|
||||
- ▶️ **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
|
||||
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
|
||||
@@ -89,7 +92,7 @@ For the remote server (receives trip logs + dashcam uploads), see [server/README
|
||||
|
||||
## 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,
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
health, alerts, settings, and a final UI polish pass. Full phase history, verification notes, and
|
||||
current scope live in [PLAN.md](./PLAN.md).
|
||||
planned later instead. The next roadmap work is trip history, followed by Pi health, alerts, settings, and a final UI
|
||||
polish pass. Full phase history, verification notes, and current scope live in
|
||||
[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) {
|
||||
const cell = document.createElement('td');
|
||||
cell.textContent = text;
|
||||
@@ -93,7 +84,7 @@ function renderDashcamFiles(summary) {
|
||||
row.appendChild(selection);
|
||||
|
||||
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()));
|
||||
|
||||
const actions = document.createElement('td');
|
||||
@@ -116,7 +107,7 @@ function renderDashcamFiles(summary) {
|
||||
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) {
|
||||
fileSummary.textContent += ` (${summary.errors.length} file error(s))`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
(function () {
|
||||
"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) {
|
||||
const leftValue = left.dataset[key] || "";
|
||||
const rightValue = right.dataset[key] || "";
|
||||
@@ -66,4 +78,5 @@
|
||||
}
|
||||
|
||||
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();
|
||||
}());
|
||||
@@ -37,4 +37,20 @@
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
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>
|
||||
</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-header py-3">
|
||||
<p class="text-primary m-0 fw-bold">Remote server upload</p>
|
||||
@@ -85,4 +122,5 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/dash.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/local-videos.js') }}"></script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user