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]:
|
def list_video_files() -> List[pathlib.Path]:
|
||||||
exts = {".mp4", ".avi", ".mov", ".mkv"}
|
exts = {".mp4", ".avi", ".mov", ".mkv"}
|
||||||
files: List[pathlib.Path] = []
|
files: List[pathlib.Path] = []
|
||||||
@@ -56,27 +145,98 @@ def path_from_file_id(fid: str) -> pathlib.Path:
|
|||||||
return p
|
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)
|
THUMBS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
thumb_name = file_id_from_path(p) + f"_{width}x{height}.jpg"
|
thumb_name = file_id_from_path(p) + f"_{width}x{height}.jpg"
|
||||||
out = THUMBS_DIR / thumb_name
|
out = THUMBS_DIR / thumb_name
|
||||||
|
|
||||||
|
# Check if thumbnail already exists and is valid
|
||||||
if out.exists():
|
if out.exists():
|
||||||
return out
|
try:
|
||||||
# generate from 1s offset or 0 if too short
|
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:
|
||||||
|
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 = [
|
cmd = [
|
||||||
"ffmpeg", "-y",
|
"ffmpeg", "-y",
|
||||||
"-ss", "1",
|
"-ss", str(seek_time),
|
||||||
"-i", str(p),
|
"-i", str(p),
|
||||||
"-vframes", "1",
|
"-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",
|
"-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)
|
str(out)
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
result = subprocess.run(
|
||||||
except subprocess.CalledProcessError:
|
cmd,
|
||||||
raise HTTPException(status_code=500, detail="Failed to generate thumbnail")
|
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
|
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")
|
@app.get("/health")
|
||||||
def health() -> dict:
|
def health() -> dict:
|
||||||
|
|||||||
Reference in New Issue
Block a user