Add local dashcam video library

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-09 19:04:47 -04:00
co-authored by Copilot
parent c57457b934
commit 3a981f5c6d
14 changed files with 659 additions and 29 deletions
+1
View File
@@ -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
+31 -1
View File
@@ -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
+49 -3
View File
@@ -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())
+11
View File
@@ -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
View File
@@ -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,
+40
View File
@@ -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:
+15 -1
View File
@@ -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,6 +253,11 @@ 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):
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) sha256 = _hash_dashcam_file(abs_path, rel_path)
if app_sql.dashcam_file_marked_uploaded(rel_path, sha256): if app_sql.dashcam_file_marked_uploaded(rel_path, sha256):
continue continue
@@ -260,6 +265,8 @@ def _upload_dashcam_files():
_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
+210
View File
@@ -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}
+7 -4
View File
@@ -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
View File
@@ -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))`;
} }
+13
View File
@@ -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 });
}()); }());
+179
View File
@@ -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();
}());
+16
View File
@@ -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;
}
+38
View File
@@ -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 %}