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
import os, time
from gevent.pywsgi import WSGIServer
@@ -149,12 +149,48 @@ def local_video_file():
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),
)
file_size = os.path.getsize(path)
mime = "video/mp4"
range_header = request.headers.get("Range")
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')
def local_video_thumbnail():
@@ -166,6 +202,8 @@ def local_video_thumbnail():
except FileNotFoundError:
return jsonify(error=f"Local video not found: {relative_path}"), 404
except RuntimeError as error:
if str(error) == "generating":
return jsonify(status="generating"), 202
return jsonify(error=str(error)), 422
return send_file(path, conditional=True, mimetype="image/jpeg")
+37 -15
View File
@@ -3,6 +3,7 @@ import os
import subprocess
import time
import gevent
from gevent import sleep
from gevent.lock import Semaphore
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")
RETENTION_SETTING = "local_video_retention_days"
_thumbnail_lock = Semaphore()
# Tracks which relative paths are currently being generated (set of strings)
_generating = set()
_generating_lock = Semaphore()
def _local_path(relative_path):
@@ -72,24 +76,44 @@ def get_local_video_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_mtime = os.path.getmtime(source_path)
with _thumbnail_lock:
# Fast path: thumbnail already exists and is fresh.
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."
)
# 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:
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",
@@ -116,27 +140,25 @@ def get_thumbnail_path(relative_path):
stderr=subprocess.PIPE,
text=True,
)
except FileNotFoundError as e:
raise RuntimeError("ffmpeg is not installed on this device.") from e
except FileNotFoundError:
return
while process.poll() is None:
sleep(0.05)
_stdout, error = process.communicate()
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
return
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)
finally:
with _generating_lock:
_generating.discard(relative_path)
def delete_local_video(relative_path):
+200 -10
View File
@@ -4,23 +4,213 @@
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;
// ── 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) {
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);
if (!entry.isIntersecting) return;
observer.unobserve(entry.target);
loadThumbnail(entry.target);
});
}, { 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) {
return fetch(url, options).then(function (response) {
return response.json().then(function (data) {
+12 -9
View File
@@ -99,16 +99,19 @@
</div>
</div>
{# ── Video player (hidden until opened) ─────────────────────────────── #}
<div id="local-video-player-panel" class="card shadow-sm mb-3" hidden>
<div class="card-header py-2 d-flex align-items-center justify-content-between">
<p id="local-video-player-title" class="text-primary m-0 fw-bold small"></p>
<button id="local-video-player-close" class="btn btn-sm btn-outline-secondary" type="button">
<i class="fas fa-times me-1"></i>Close
</button>
{# ── Video player modal ──────────────────────────────────────────────── #}
<div class="modal fade" id="video-player-modal" tabindex="-1" aria-labelledby="video-player-modal-title" aria-modal="true" role="dialog">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-dark">
<div class="modal-header border-secondary py-2">
<h6 class="modal-title text-light text-truncate me-2" id="video-player-modal-title"></h6>
<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 class="card-body p-0">
<video id="local-video-player" class="local-video-player" controls preload="metadata"></video>
</div>
</div>
{% endblock %}