"""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}