From c4df92720b1f8cb27e535d0802606ba2b1e67f80 Mon Sep 17 00:00:00 2001 From: Claudio Date: Tue, 12 May 2026 00:03:15 +0200 Subject: [PATCH] feat(playback): pre-transcode HEVC videos in background; veryfast preset Long videos blocked /playback for the entire encode duration. The fix is to populate the cache before the user clicks, not when they click. Changes: - Extract ffprobe + ffmpeg helpers to services/video.py so the request handler and the background task share one sync implementation. The endpoint wraps calls in asyncio.to_thread; celery just calls them. - New tasks/video.py with pretranscode_video. Idempotent: skips when the cache is already current and skips passthrough-safe sources (h264 in mp4/m4v/webm). 30-min task time limit so the long-tail files (3GP archive, multi-minute 1080p clips) still complete. - scan_folder now dispatches pretranscode_video alongside generate_thumbnails / extract_metadata for any new video row. - POST /library/maintenance/backfill-video-cache enqueues every active video so the existing library catches up. - libx264 preset bumped from fast to veryfast. ~2x throughput on this CPU-only box, output a few % larger but well within disk budget. - /playback simplifies to: cache check, passthrough if h264 in web-safe container, else sync transcode (still there as fallback for races against the queued task). Once the backfill task drains, /playback should be near-instant for every video. Any video added afterwards is pre-transcoded at scan time, so the user keeps that property going forward. --- backend/app/routers/library.py | 28 +++++++ backend/app/routers/photos.py | 142 ++++----------------------------- backend/app/services/video.py | 132 ++++++++++++++++++++++++++++++ backend/app/tasks/celery.py | 5 ++ backend/app/tasks/scan.py | 11 +++ backend/app/tasks/video.py | 62 ++++++++++++++ 6 files changed, 253 insertions(+), 127 deletions(-) create mode 100644 backend/app/services/video.py create mode 100644 backend/app/tasks/video.py diff --git a/backend/app/routers/library.py b/backend/app/routers/library.py index baf9ed6..6cf4470 100644 --- a/backend/app/routers/library.py +++ b/backend/app/routers/library.py @@ -842,6 +842,34 @@ async def trigger_backfill_phashes(current_user: User = Depends(get_current_user return {"status": "error", "message": str(e)} +@router.post("/maintenance/backfill-video-cache") +async def trigger_backfill_video_cache( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Pre-transcode every active video in the library so /playback hits + the cache on first user click instead of paying the encode cost + inline. Idempotent — pretranscode_video skips photos whose cache is + already populated and current. CPU-bound; runs on the low-priority + queue so it doesn't fight thumbnails or other user-facing tasks.""" + from app.tasks.video import pretranscode_video + result = await db.execute( + select(Photo.id, Photo.filepath).where( + Photo.media_type == 'video', + Photo.is_discarded.is_(False), + ) + ) + rows = result.all() + queued = 0 + for photo_id, filepath in rows: + try: + pretranscode_video.delay(photo_id, filepath) + queued += 1 + except Exception as e: + logger.warning(f"failed to queue pretranscode for {photo_id}: {e}") + return {"status": "queued", "count": queued} + + @router.post("/maintenance/start-watcher") async def start_file_watcher(current_user: User = Depends(get_current_user)): """Start the filesystem watcher. Uses a Redis lock so only one diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index 6392693..d161374 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -14,7 +14,6 @@ import asyncio import base64 import json import os -import subprocess import logging logger = logging.getLogger(__name__) @@ -28,6 +27,7 @@ from app.models.tags import photo_tags from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction from app.services.exif_writer import ExifWriteError, write_taken_at from app.services.date_guess import has_date_warning as compute_date_warning +from app.services import video as video_service from app.dependencies import ( get_current_user, get_current_user_media, get_user_photo, get_user_or_shared_heap, get_user_or_shared_folder, @@ -854,23 +854,6 @@ async def get_original( ) -# Cached H.264/MP4 transcodes for /playback. iPhone shoots HEVC Main 10 by -# default and Chrome/Firefox can't decode 10-bit HEVC reliably, so anything -# that isn't already h264 in a web-safe container gets transcoded once and -# served from this directory thereafter. -_VIDEO_CACHE_DIR = Path('/data/video-cache') -_VIDEO_CACHE_DIR.mkdir(parents=True, exist_ok=True) - -# Video codecs that play in