Add background thumbnail generator to media API

- Implemented a background worker for generating video thumbnails, enhancing media processing capabilities.
- Added startup and shutdown events to manage the thumbnail generator's lifecycle.
- Refactored thumbnail generation logic to handle file processing more robustly, including checks for existing thumbnails and file accessibility.
- Updated existing functions to integrate with the new background processing approach, ensuring seamless thumbnail generation on demand.
This commit is contained in:
salirezav
2025-12-02 12:34:00 -05:00
parent cb48020932
commit d454c64168
2 changed files with 177 additions and 15 deletions

View File

@@ -118,3 +118,5 @@ export const MqttDebugPanel: React.FC<MqttDebugPanelProps> = ({ isOpen, onClose
)
}

View File

@@ -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")