Files
yaris-obd/modules/app_video.py
T
Mathieu MoranandCopilot b711d421cc fix(videos): real ffmpeg progress via -progress pipe, exclude .converting.mp4
- ffmpeg -progress pipe:1 streams out_time_ms lines; we parse them in the
  reading loop so % = out_time_s / duration_s (actual encode position)
- find_video_files() skips *.converting.mp4 temp files so they are never
  listed, uploaded, or shown in the UI

Co-authored-by: Copilot <[email protected]>
2026-08-10 02:13:45 -04:00

360 lines
13 KiB
Python

"""Local imported-video browsing, playback, thumbnails, and retention."""
import os
import subprocess
import time
import threading
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"
CONVERT_SETTING = "video_convert_enabled"
_thumbnail_lock = Semaphore()
_generating = set()
_generating_lock = Semaphore()
_converting = {} # path -> {"started_at": float, "source_size": int, "tmp_path": str}
_converting_lock = Semaphore()
def is_convert_enabled():
return app_sql.get_setting(CONVERT_SETTING, "0") == "1"
def queue_convert(relative_path, force=False):
"""Spawn a background thread to re-encode relative_path to web-ready MP4.
Returns True if queued, False if already converting, None if skipped (disabled/MP4).
Set force=True to bypass the is_convert_enabled() check (manual trigger)."""
if not force and not is_convert_enabled():
return None
if relative_path.lower().endswith(".mp4"):
return None
with _converting_lock:
if relative_path in _converting:
return False
_converting[relative_path] = {"started_at": time.time(), "duration_s": 0, "tmp_path": ""}
t = threading.Thread(target=_convert_bg, args=(relative_path,), daemon=True)
t.start()
return True
def _get_duration_s(path):
"""Return video duration in seconds via ffprobe, or None on failure."""
try:
result = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", path],
capture_output=True, text=True, timeout=10,
)
return float(result.stdout.strip())
except Exception:
return None
def _convert_bg(relative_path):
try:
source_path = _local_path(relative_path)
mp4_relative = os.path.splitext(relative_path)[0] + ".mp4"
mp4_path = _local_path_unchecked(mp4_relative)
if not app_dash.begin_local_video_use(relative_path):
return
try:
tmp_path = mp4_path + ".converting.mp4"
duration_s = _get_duration_s(source_path) or 0
with _converting_lock:
if relative_path in _converting:
_converting[relative_path]["duration_s"] = duration_s
_converting[relative_path]["tmp_path"] = tmp_path
_converting[relative_path]["out_time_s"] = 0.0
cmd = [
"ffmpeg", "-nostdin", "-loglevel", "error", "-y",
"-i", source_path,
"-c:v", "h264_v4l2m2m",
"-b:v", "4M",
"-c:a", "aac",
"-b:a", "128k",
"-movflags", "faststart",
"-f", "mp4",
"-progress", "pipe:1",
tmp_path,
]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
text=True, bufsize=1)
# Read progress lines from stdout
for line in proc.stdout:
line = line.strip()
if line.startswith("out_time_ms="):
try:
ms = int(line.split("=", 1)[1])
with _converting_lock:
if relative_path in _converting:
_converting[relative_path]["out_time_s"] = ms / 1_000_000.0
except (ValueError, IndexError):
pass
proc.wait()
if proc.returncode == 0 and os.path.isfile(tmp_path) and os.path.getsize(tmp_path) > 0:
os.replace(tmp_path, mp4_path)
# Delete original now that MP4 is ready
try:
os.remove(source_path)
except OSError:
pass
# Also remove old thumbnail so it gets regenerated from MP4
_source, thumb = _thumbnail_path(relative_path)
if os.path.isfile(thumb):
try:
os.remove(thumb)
except OSError:
pass
elif os.path.exists(tmp_path):
os.remove(tmp_path)
finally:
app_dash.end_local_video_use(relative_path)
finally:
with _converting_lock:
_converting.pop(relative_path, None)
def _local_path(relative_path):
return app_dash.safe_video_path(app_dash.DEFAULT_DESTINATION, relative_path)
def _local_path_unchecked(relative_path):
"""Build absolute path without requiring the file to already exist."""
base = os.path.realpath(app_dash.DEFAULT_DESTINATION)
target = os.path.realpath(os.path.join(base, relative_path))
if os.path.commonpath([base, target]) != base:
raise ValueError("Path escapes video directory.")
return target
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
with _converting_lock:
conv_info = _converting.get(relative_path)
converting = conv_info is not None
convert_progress = None
if converting and conv_info:
elapsed = time.time() - conv_info["started_at"]
duration_s = conv_info.get("duration_s", 0)
out_time_s = conv_info.get("out_time_s", 0.0)
pct = min(round(out_time_s / duration_s * 100), 99) if duration_s > 0 else 0
convert_progress = {
"elapsed_s": int(elapsed),
"pct": pct,
}
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,
"converting": converting,
"convert_progress": convert_progress,
}
)
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}