"""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): """Spawn a background greenlet to re-encode relative_path to web-ready MP4. No-op if conversion is disabled, already running, or file is already MP4.""" if not is_convert_enabled(): return if relative_path.lower().endswith(".mp4"): return with _converting_lock: if relative_path in _converting: return _converting.add(relative_path) gevent.sleep(0) # yield before spawning thread t = threading.Thread(target=_convert_bg, args=(relative_path,), daemon=True) t.start() 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}