diff --git a/management-dashboard-web-app/src/components/MqttDebugPanel.tsx b/management-dashboard-web-app/src/components/MqttDebugPanel.tsx index e558774..71508c7 100644 --- a/management-dashboard-web-app/src/components/MqttDebugPanel.tsx +++ b/management-dashboard-web-app/src/components/MqttDebugPanel.tsx @@ -118,3 +118,5 @@ export const MqttDebugPanel: React.FC = ({ isOpen, onClose ) } + + diff --git a/media-api/main.py b/media-api/main.py index 980b7ce..0816a34 100644 --- a/media-api/main.py +++ b/media-api/main.py @@ -30,6 +30,95 @@ app.add_middleware( ) +# Background thumbnail generator +_thumbnail_generator_running = False +_thumbnail_generator_thread = None + + +def thumbnail_generator_worker(): + """ + Background worker that periodically scans for videos and generates missing thumbnails. + Runs continuously in the background. + """ + global _thumbnail_generator_running + + print("[Thumbnail Generator] Background thumbnail generator started", flush=True) + + # Track processed files to avoid reprocessing + processed_files = set() + + # Scan interval (in seconds) + scan_interval = int(os.getenv("THUMBNAIL_SCAN_INTERVAL", "30")) # Default: scan every 30 seconds + + while _thumbnail_generator_running: + try: + # Get all video files + video_files = list_video_files() + + # Process files that don't have thumbnails yet + for video_path in video_files: + if not _thumbnail_generator_running: + break + + try: + # Check if we've already processed this file recently + file_id = str(video_path) + if file_id in processed_files: + continue + + # Try to generate thumbnail + success = generate_thumbnail_background(video_path) + + if success: + processed_files.add(file_id) + print(f"[Thumbnail Generator] Generated thumbnail for: {video_path.name}", flush=True) + # If failed, we'll try again on next scan (file might still be recording) + + except Exception as e: + # Log error but continue processing other files + print(f"[Thumbnail Generator] Error processing {video_path}: {e}", flush=True) + continue + + # Clean up old entries from processed_files (keep last 1000) + if len(processed_files) > 1000: + # Keep only recent entries (this is a simple approach) + processed_files = set(list(processed_files)[-500:]) + + except Exception as e: + print(f"[Thumbnail Generator] Error in scan loop: {e}", flush=True) + + # Sleep before next scan + time.sleep(scan_interval) + + print("[Thumbnail Generator] Background thumbnail generator stopped", flush=True) + + +@app.on_event("startup") +def startup_event(): + """Start background thumbnail generator when app starts""" + global _thumbnail_generator_running, _thumbnail_generator_thread + + _thumbnail_generator_running = True + _thumbnail_generator_thread = threading.Thread( + target=thumbnail_generator_worker, + daemon=True, + name="ThumbnailGenerator" + ) + _thumbnail_generator_thread.start() + print("[Media API] Background thumbnail generator started", flush=True) + + +@app.on_event("shutdown") +def shutdown_event(): + """Stop background thumbnail generator when app shuts down""" + global _thumbnail_generator_running + + _thumbnail_generator_running = False + if _thumbnail_generator_thread: + _thumbnail_generator_thread.join(timeout=5) + print("[Media API] Background thumbnail generator stopped", flush=True) + + def list_video_files() -> List[pathlib.Path]: exts = {".mp4", ".avi", ".mov", ".mkv"} files: List[pathlib.Path] = [] @@ -56,26 +145,97 @@ def path_from_file_id(fid: str) -> pathlib.Path: return p -def ensure_thumbnail(p: pathlib.Path, width: int = 320, height: int = 180) -> pathlib.Path: +def generate_thumbnail_background(p: pathlib.Path, width: int = 320, height: int = 180) -> bool: + """ + Generate thumbnail in background (non-blocking, doesn't raise exceptions). + Returns True if successful, False otherwise. + """ THUMBS_DIR.mkdir(parents=True, exist_ok=True) thumb_name = file_id_from_path(p) + f"_{width}x{height}.jpg" out = THUMBS_DIR / thumb_name + + # Check if thumbnail already exists and is valid if out.exists(): - return out - # generate from 1s offset or 0 if too short - cmd = [ - "ffmpeg", "-y", - "-ss", "1", - "-i", str(p), - "-vframes", "1", - "-vf", f"scale='min({width},iw)':-2,scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2", - str(out) - ] + try: + if out.stat().st_size > 0: + return True # Thumbnail already exists and is valid + else: + # Empty thumbnail, remove it + out.unlink() + except OSError: + pass # Ignore errors checking/removing + + # Check if video file exists and is readable + if not p.exists(): + return False + try: - subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except subprocess.CalledProcessError: - raise HTTPException(status_code=500, detail="Failed to generate thumbnail") - return out + file_size = p.stat().st_size + if file_size == 0: + return False # File is empty, skip + except OSError: + return False # Cannot access file + + # For very small files (< 1MB), they might still be recording + # Wait a bit and check if size is stable + if file_size < 1024 * 1024: + time.sleep(1.0) # Wait 1 second + try: + new_size = p.stat().st_size + # If file grew significantly, it's likely being written - skip for now + if new_size > file_size * 1.1: + return False # File is actively being written, skip + except OSError: + return False + + # Try to generate thumbnail - try 1s first, then 0s if that fails + for seek_time in [1.0, 0.0]: + cmd = [ + "ffmpeg", "-y", + "-ss", str(seek_time), + "-i", str(p), + "-vframes", "1", + "-vf", f"scale='min({width},iw)':-2,scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2", + str(out) + ] + try: + result = subprocess.run( + cmd, + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=30 + ) + # Check if thumbnail was created successfully + if out.exists() and out.stat().st_size > 0: + return True + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + # Try next seek time or give up + continue + except Exception: + # Any other error, skip this file + return False + + return False # Failed to generate thumbnail + + +def ensure_thumbnail(p: pathlib.Path, width: int = 320, height: int = 180) -> pathlib.Path: + """ + Ensure thumbnail exists (for API requests). + Raises HTTPException on failure. + """ + THUMBS_DIR.mkdir(parents=True, exist_ok=True) + thumb_name = file_id_from_path(p) + f"_{width}x{height}.jpg" + out = THUMBS_DIR / thumb_name + if out.exists() and out.stat().st_size > 0: + return out + + # Try to generate on-demand + if generate_thumbnail_background(p, width, height): + if out.exists() and out.stat().st_size > 0: + return out + + raise HTTPException(status_code=500, detail="Failed to generate thumbnail") @app.get("/health")