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:
@@ -118,3 +118,5 @@ export const MqttDebugPanel: React.FC<MqttDebugPanelProps> = ({ isOpen, onClose
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user