- 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]>
360 lines
13 KiB
Python
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}
|