fix(dashcam): range-request video streaming, modal player, async thumbnails

- app.py: replace send_file with manual Range-aware chunked generator;
  returns 206 on partial requests so browsers can seek/buffer properly.
  Thumbnail route returns 202 while ffmpeg is generating.
- app_video.py: thumbnail generation moved to background gevent greenlet
  (_generate_thumbnail_bg). get_thumbnail_path returns immediately with
  RuntimeError('generating') if not cached; ffmpeg only ever runs once
  per video (result cached in dash/thumbnails/).
- dash.html: replaced bottom card player with Bootstrap modal (modal-xl,
  centered, dark bg) — video pops up in place regardless of scroll position.
- local-videos.js: rewritten to use Bootstrap modal; thumbnail loader
  polls with fetch() and retries on 202 every 2s (up to 15x) instead of
  setting img.src directly (which can't detect 202).

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-10 00:01:49 -04:00
co-authored by Copilot
parent 35ec705fe2
commit 600fd41353
4 changed files with 300 additions and 47 deletions
+45 -7
View File
@@ -1,4 +1,4 @@
from flask import Flask, render_template, make_response, json, jsonify, request, send_file from flask import Flask, render_template, make_response, json, jsonify, request, send_file, Response
from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd, app_sql, app_dash, app_upload, app_video, app_health, app_alerts, app_settings, app_network from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd, app_sql, app_dash, app_upload, app_video, app_health, app_alerts, app_settings, app_network
import os, time import os, time
from gevent.pywsgi import WSGIServer from gevent.pywsgi import WSGIServer
@@ -149,12 +149,48 @@ def local_video_file():
return jsonify(error=str(error)), 400 return jsonify(error=str(error)), 400
except FileNotFoundError: except FileNotFoundError:
return jsonify(error=f"Local video not found: {relative_path}"), 404 return jsonify(error=f"Local video not found: {relative_path}"), 404
return send_file(
path, file_size = os.path.getsize(path)
conditional=True, mime = "video/mp4"
as_attachment=False, range_header = request.headers.get("Range")
download_name=os.path.basename(path),
) if range_header:
# Parse "bytes=start-end"
byte1, byte2 = 0, None
m = range_header.strip().replace("bytes=", "").split("-")
byte1 = int(m[0]) if m[0] else 0
byte2 = int(m[1]) if len(m) > 1 and m[1] else file_size - 1
length = byte2 - byte1 + 1
def generate_range():
with open(path, "rb") as f:
f.seek(byte1)
remaining = length
while remaining > 0:
chunk = f.read(min(65536, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
resp = Response(generate_range(), 206, mimetype=mime, direct_passthrough=True)
resp.headers["Content-Range"] = f"bytes {byte1}-{byte2}/{file_size}"
resp.headers["Accept-Ranges"] = "bytes"
resp.headers["Content-Length"] = str(length)
return resp
def generate_full():
with open(path, "rb") as f:
while True:
chunk = f.read(65536)
if not chunk:
break
yield chunk
resp = Response(generate_full(), 200, mimetype=mime, direct_passthrough=True)
resp.headers["Accept-Ranges"] = "bytes"
resp.headers["Content-Length"] = str(file_size)
return resp
@app.route('/api/local-video/thumbnail') @app.route('/api/local-video/thumbnail')
def local_video_thumbnail(): def local_video_thumbnail():
@@ -166,6 +202,8 @@ def local_video_thumbnail():
except FileNotFoundError: except FileNotFoundError:
return jsonify(error=f"Local video not found: {relative_path}"), 404 return jsonify(error=f"Local video not found: {relative_path}"), 404
except RuntimeError as error: except RuntimeError as error:
if str(error) == "generating":
return jsonify(status="generating"), 202
return jsonify(error=str(error)), 422 return jsonify(error=str(error)), 422
return send_file(path, conditional=True, mimetype="image/jpeg") return send_file(path, conditional=True, mimetype="image/jpeg")
+37 -15
View File
@@ -3,6 +3,7 @@ import os
import subprocess import subprocess
import time import time
import gevent
from gevent import sleep from gevent import sleep
from gevent.lock import Semaphore from gevent.lock import Semaphore
from modules import app_dash, app_sql from modules import app_dash, app_sql
@@ -12,6 +13,9 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
THUMBNAIL_DIR = os.path.join(BASE_DIR, "dash", "thumbnails") THUMBNAIL_DIR = os.path.join(BASE_DIR, "dash", "thumbnails")
RETENTION_SETTING = "local_video_retention_days" RETENTION_SETTING = "local_video_retention_days"
_thumbnail_lock = Semaphore() _thumbnail_lock = Semaphore()
# Tracks which relative paths are currently being generated (set of strings)
_generating = set()
_generating_lock = Semaphore()
def _local_path(relative_path): def _local_path(relative_path):
@@ -72,24 +76,44 @@ def get_local_video_path(relative_path):
def get_thumbnail_path(relative_path): def get_thumbnail_path(relative_path):
"""Return the thumbnail path if it exists, or kick off background generation.
Raises:
FileNotFoundError: source video not found.
ValueError: path escapes base directory.
RuntimeError("generating"): thumbnail is being generated — caller should 202.
RuntimeError: ffmpeg failed (only propagated synchronously on first generation if
the greenlet hasn't launched yet, but typically never reaches caller).
"""
source_path, thumbnail_path = _thumbnail_path(relative_path) source_path, thumbnail_path = _thumbnail_path(relative_path)
source_mtime = os.path.getmtime(source_path) source_mtime = os.path.getmtime(source_path)
with _thumbnail_lock: # Fast path: thumbnail already exists and is fresh.
if ( if (
os.path.isfile(thumbnail_path) os.path.isfile(thumbnail_path)
and os.path.getmtime(thumbnail_path) >= source_mtime and os.path.getmtime(thumbnail_path) >= source_mtime
): ):
return thumbnail_path 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."
)
# Check if generation is already running.
with _generating_lock:
if relative_path in _generating:
raise RuntimeError("generating")
_generating.add(relative_path)
# Spawn background greenlet so the HTTP thread returns immediately.
gevent.spawn(_generate_thumbnail_bg, relative_path, source_path, thumbnail_path)
raise RuntimeError("generating")
def _generate_thumbnail_bg(relative_path, source_path, thumbnail_path):
"""Generate thumbnail in background greenlet. Cleans up _generating on exit."""
try:
if not app_dash.begin_local_video_use(relative_path):
return
try: try:
os.makedirs(os.path.dirname(thumbnail_path), exist_ok=True) os.makedirs(os.path.dirname(thumbnail_path), exist_ok=True)
temporary_path = thumbnail_path + ".tmp.jpg" temporary_path = thumbnail_path + ".tmp.jpg"
error = None
for seek_seconds in ("1", "0"): for seek_seconds in ("1", "0"):
command = [ command = [
"ffmpeg", "ffmpeg",
@@ -116,27 +140,25 @@ def get_thumbnail_path(relative_path):
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, text=True,
) )
except FileNotFoundError as e: except FileNotFoundError:
raise RuntimeError("ffmpeg is not installed on this device.") from e return
while process.poll() is None: while process.poll() is None:
sleep(0.05) sleep(0.05)
_stdout, error = process.communicate() process.communicate()
if ( if (
process.returncode == 0 process.returncode == 0
and os.path.isfile(temporary_path) and os.path.isfile(temporary_path)
and os.path.getsize(temporary_path) > 0 and os.path.getsize(temporary_path) > 0
): ):
os.replace(temporary_path, thumbnail_path) os.replace(temporary_path, thumbnail_path)
return thumbnail_path return
if os.path.exists(temporary_path): if os.path.exists(temporary_path):
os.remove(temporary_path) os.remove(temporary_path)
raise RuntimeError(
(error or "").strip()
or f"ffmpeg could not extract a frame from {relative_path}"
)
finally: finally:
app_dash.end_local_video_use(relative_path) app_dash.end_local_video_use(relative_path)
finally:
with _generating_lock:
_generating.discard(relative_path)
def delete_local_video(relative_path): def delete_local_video(relative_path):
+200 -10
View File
@@ -4,23 +4,213 @@
const summary = document.getElementById("local-video-summary"); const summary = document.getElementById("local-video-summary");
const body = document.querySelector("#local-video-list [data-data-list-body]"); const body = document.querySelector("#local-video-list [data-data-list-body]");
const refreshButton = document.getElementById("local-videos-refresh-btn"); 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; let playingPath = null;
// ── Modal player ──────────────────────────────────────────────────────────
const modalEl = document.getElementById("video-player-modal");
const modalTitle = document.getElementById("video-player-modal-title");
const player = document.getElementById("local-video-player");
const bsModal = new bootstrap.Modal(modalEl);
modalEl.addEventListener("hidden.bs.modal", function () {
player.pause();
player.removeAttribute("src");
player.load();
playingPath = null;
});
function playVideo(path) {
playingPath = path;
modalTitle.textContent = path.split("/").pop();
player.src = `/api/local-video/file?path=${encodeURIComponent(path)}`;
bsModal.show();
player.load();
player.play().catch(function () {});
}
// ── Thumbnail loading with retry on 202 ──────────────────────────────────
const THUMB_RETRY_MS = 2000;
const THUMB_MAX_RETRIES = 15;
function loadThumbnail(image) {
const url = `/api/local-video/thumbnail?path=${encodeURIComponent(image.dataset.path)}`;
let attempts = 0;
function attempt() {
fetch(url)
.then(function (resp) {
if (resp.status === 202 && attempts < THUMB_MAX_RETRIES) {
attempts++;
setTimeout(attempt, THUMB_RETRY_MS);
return;
}
if (!resp.ok) {
image.hidden = true;
return;
}
return resp.blob().then(function (blob) {
image.src = URL.createObjectURL(blob);
});
})
.catch(function () {
image.hidden = true;
});
}
attempt();
}
const thumbnailObserver = new IntersectionObserver(function (entries, observer) { const thumbnailObserver = new IntersectionObserver(function (entries, observer) {
entries.forEach(function (entry) { entries.forEach(function (entry) {
if (!entry.isIntersecting) { if (!entry.isIntersecting) return;
return; observer.unobserve(entry.target);
} loadThumbnail(entry.target);
const image = entry.target;
image.src = `/api/local-video/thumbnail?path=${encodeURIComponent(image.dataset.path)}`;
observer.unobserve(image);
}); });
}, { rootMargin: "200px" }); }, { rootMargin: "200px" });
// ── Utilities ─────────────────────────────────────────────────────────────
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;
}
// ── File list rendering ───────────────────────────────────────────────────
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;
// Thumbnail cell
const preview = document.createElement("td");
const image = document.createElement("img");
image.className = "local-video-thumbnail";
image.alt = "";
image.dataset.path = file.path;
preview.appendChild(image);
row.appendChild(preview);
thumbnailObserver.observe(image);
// File name
const nameCell = document.createElement("td");
nameCell.className = "dash-col-file";
nameCell.textContent = file.path;
row.appendChild(nameCell);
// Size
const sizeCell = textCell(YarisUI.formatBytes(file.size));
sizeCell.className = "dash-col-size";
row.appendChild(sizeCell);
// Date
const dateCell = textCell(new Date(file.modified_at * 1000).toLocaleString());
dateCell.className = "dash-col-date";
row.appendChild(dateCell);
// Upload badge
const upload = document.createElement("td");
upload.className = "dash-col-upload";
const badge = document.createElement("span");
badge.className = file.uploaded ? "badge bg-success" : "badge bg-warning text-dark";
badge.textContent = file.uploaded ? "✓" : "…";
upload.appendChild(badge);
row.appendChild(upload);
if (file.uploaded) uploadedCount++;
// Actions
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 btn-icon-mobile";
playButton.dataset.playPath = file.path;
playButton.innerHTML = '<i class="fas fa-play"></i><span class="btn-label"> Play</span>';
actions.appendChild(playButton);
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.className = "btn btn-sm btn-outline-danger btn-icon-mobile";
deleteButton.dataset.deleteLocalPath = file.path;
deleteButton.dataset.confirm = `Delete the local Pi copy of "${file.path}"?`;
deleteButton.innerHTML = '<i class="fas fa-trash"></i><span class="btn-label"> Del</span>';
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 += `; > ${result.retention_days}d uploaded auto-deleted`;
}
if (result.errors && result.errors.length) {
summary.textContent += ` (${result.errors.length} 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) bsModal.hide();
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) {
if (!confirm(deleteButton.dataset.confirm)) return;
deleteButton.disabled = true;
deleteVideo(deleteButton.dataset.deleteLocalPath).finally(function () {
deleteButton.disabled = false;
});
}
});
refreshButton.addEventListener("click", refreshFiles);
refreshFiles();
}());
function fetchJson(url, options) { function fetchJson(url, options) {
return fetch(url, options).then(function (response) { return fetch(url, options).then(function (response) {
return response.json().then(function (data) { return response.json().then(function (data) {
+12 -9
View File
@@ -99,16 +99,19 @@
</div> </div>
</div> </div>
{# ── Video player (hidden until opened) ─────────────────────────────── #} {# ── Video player modal ──────────────────────────────────────────────── #}
<div id="local-video-player-panel" class="card shadow-sm mb-3" hidden> <div class="modal fade" id="video-player-modal" tabindex="-1" aria-labelledby="video-player-modal-title" aria-modal="true" role="dialog">
<div class="card-header py-2 d-flex align-items-center justify-content-between"> <div class="modal-dialog modal-xl modal-dialog-centered">
<p id="local-video-player-title" class="text-primary m-0 fw-bold small"></p> <div class="modal-content bg-dark">
<button id="local-video-player-close" class="btn btn-sm btn-outline-secondary" type="button"> <div class="modal-header border-secondary py-2">
<i class="fas fa-times me-1"></i>Close <h6 class="modal-title text-light text-truncate me-2" id="video-player-modal-title"></h6>
</button> <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0">
<video id="local-video-player" class="w-100 d-block" style="max-height:75vh;background:#000;"
controls preload="metadata"></video>
</div>
</div> </div>
<div class="card-body p-0">
<video id="local-video-player" class="local-video-player" controls preload="metadata"></video>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}