Add local dashcam video library
Co-authored-by: Copilot <[email protected]>
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user