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:
+42
-20
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user