Files
yaris-obd/modules/app_video.py
T
2026-08-09 19:04:47 -04:00

211 lines
7.0 KiB
Python

"""Local imported-video browsing, playback, thumbnails, and retention."""
import os
import subprocess
import time
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()
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):
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."
)
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",
"-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 as e:
raise RuntimeError("ffmpeg is not installed on this device.") from e
while process.poll() is None:
sleep(0.05)
_stdout, error = 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
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)
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}