Files
yaris-obd/modules/app_video.py
T
Mathieu MoranandCopilot 600fd41353 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]>
2026-08-10 00:01:49 -04:00

233 lines
7.9 KiB
Python

"""Local imported-video browsing, playback, thumbnails, and retention."""
import os
import subprocess
import time
import gevent
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()
# Tracks which relative paths are currently being generated (set of strings)
_generating = set()
_generating_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):
"""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)
# 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"
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:
return
while process.poll() is None:
sleep(0.05)
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
if os.path.exists(temporary_path):
os.remove(temporary_path)
finally:
app_dash.end_local_video_use(relative_path)
finally:
with _generating_lock:
_generating.discard(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}