Files
yaris-obd/modules/app_video.py
T
Mathieu MoranandCopilot c995599512 feat(videos): add manual Convert button per MOV file on Pi
- New POST /api/local-video/convert route triggers queue_convert(force=True)
- queue_convert() now returns True/False/None and accepts force param
  to bypass the auto-convert toggle (manual always works)
- Convert button shown for non-MP4, non-converting files in video list
- Clicking spins the button and refreshes list after 1s to show badge

Co-authored-by: Copilot <[email protected]>
2026-08-10 01:28:56 -04:00

305 lines
10 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 = set()
_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.add(relative_path)
t = threading.Thread(target=_convert_bg, args=(relative_path,), daemon=True)
t.start()
return True
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(mp4_relative)
if not app_dash.begin_local_video_use(relative_path):
return
try:
tmp_path = mp4_path + ".converting.mp4"
cmd = [
"ffmpeg", "-nostdin", "-loglevel", "error", "-y",
"-i", source_path,
"-c:v", "h264_v4l2m2m",
"-c:a", "aac",
"-movflags", "faststart",
"-f", "mp4",
tmp_path,
]
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
proc.wait() # real thread — blocking wait is fine
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.discard(relative_path)
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
with _converting_lock:
converting = relative_path in _converting
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,
}
)
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}