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
+42 -20
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:
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."
)
# 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
# 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):