feat: web video transcoding, admin playback, and viewer fixes
CI / skip-ci-check (pull_request) Successful in 1m4s
CI / lint-and-type-check (pull_request) Has been cancelled
CI / python-lint (pull_request) Has been cancelled
CI / test-backend (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / secret-scanning (pull_request) Has been cancelled
CI / dependency-scan (pull_request) Has been cancelled
CI / sast-scan (pull_request) Has been cancelled
CI / workflow-summary (pull_request) Has been cancelled

Add on-demand H.264/AAC web playback (RQ, ffmpeg) with API routes and
Next.js proxies; extend admin UI with WebPlaybackVideo and shared hooks.
Store transcode cache beside pending-photos (WEB_VIDEO_CACHE_DIR / UPLOAD_DIR)
and ignore data/web_videos. Centralize FastAPI URL helpers, optional Vite
and Next base paths for subfolder deploy, and fix modal reopen by using
router.replace when closing the home photo viewer. Include migration,
install scripts, deployment doc updates, and CI admin build env tweak.

Made-with: Cursor
This commit is contained in:
Tanya
2026-03-25 15:33:05 -04:00
parent c316da02a4
commit ff47c87e41
45 changed files with 1531 additions and 121 deletions
+98 -23
View File
@@ -7,6 +7,8 @@ from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import FileResponse, Response, StreamingResponse
from redis import Redis
from rq import Queue
from sqlalchemy.orm import Session
from backend.db.session import get_db
@@ -21,6 +23,8 @@ from backend.schemas.videos import (
IdentifyVideoRequest,
IdentifyVideoResponse,
RemoveVideoPersonResponse,
WebPlaybackPrepareResponse,
WebPlaybackStatusResponse,
)
from backend.services.video_service import (
list_videos_for_identification,
@@ -30,9 +34,19 @@ from backend.services.video_service import (
get_video_people_count,
)
from backend.services.thumbnail_service import get_video_thumbnail_path
from backend.services.web_video_service import (
expire_web_playable_if_stale,
get_web_playback_status_dict,
prepare_web_playback,
resolve_valid_playable_path,
stream_web_playable_file,
)
router = APIRouter(prefix="/videos", tags=["videos"])
_redis_conn = Redis(host="localhost", port=6379, db=0, decode_responses=False)
_video_web_queue = Queue(connection=_redis_conn)
@router.get("", response_model=ListVideosResponse)
def list_videos(
@@ -328,34 +342,18 @@ def get_video_file(
media_type = "video/mp4"
file_size = os.path.getsize(video.path)
# Get range header - Starlette normalizes headers to lowercase
range_header = request.headers.get("range")
# Debug: Write to file to verify code execution
try:
with open("/tmp/video_debug.log", "a") as f:
all_headers = {k: v for k, v in request.headers.items()}
f.write(f"Video {video_id}: range_header={range_header}, all_headers={all_headers}\n")
if hasattr(request, 'scope'):
scope_headers = request.scope.get("headers", [])
f.write(f" scope headers: {scope_headers}\n")
f.flush()
except Exception as e:
with open("/tmp/video_debug.log", "a") as f:
f.write(f"Debug write error: {e}\n")
f.flush()
# Also check request scope directly as fallback
if not range_header and hasattr(request, 'scope'):
if not range_header and hasattr(request, "scope"):
scope_headers = request.scope.get("headers", [])
for header_name, header_value in scope_headers:
if header_name.lower() == b"range":
range_header = header_value.decode() if isinstance(header_value, bytes) else header_value
with open("/tmp/video_debug.log", "a") as f:
f.write(f" Found range in scope: {range_header}\n")
f.flush()
range_header = (
header_value.decode()
if isinstance(header_value, bytes)
else header_value
)
break
if range_header:
try:
# Parse range header: "bytes=start-end"
@@ -420,6 +418,83 @@ def get_video_file(
return response
@router.post(
"/{video_id}/web-playback/prepare",
response_model=WebPlaybackPrepareResponse,
)
def prepare_video_web_playback(
video_id: int,
db: Session = Depends(get_db),
) -> WebPlaybackPrepareResponse:
"""Queue browser-safe transcoding (deduped per video). Requires Redis + RQ worker."""
try:
_video_web_queue.connection.ping()
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Redis unavailable: {exc}",
) from exc
data = prepare_web_playback(video_id, db, _video_web_queue)
if data.get("status") == "not_found":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=data.get("message", "Not found"),
)
db.commit()
return WebPlaybackPrepareResponse(
status=data["status"],
message=data.get("message", ""),
)
@router.get(
"/{video_id}/web-playback/status",
response_model=WebPlaybackStatusResponse,
)
def get_video_web_playback_status(
video_id: int,
db: Session = Depends(get_db),
) -> WebPlaybackStatusResponse:
"""Poll transcoding readiness for web playback."""
data = get_web_playback_status_dict(video_id, db)
if data["status"] == "not_found":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Video not found",
)
return WebPlaybackStatusResponse(
status=data["status"],
error=data.get("error"),
)
@router.get("/{video_id}/web-playback/stream")
def stream_video_web_playback(
video_id: int,
request: Request,
db: Session = Depends(get_db),
):
"""Stream browser-safe MP4 (after prepare + ready). Supports Range requests."""
video = (
db.query(Photo)
.filter(Photo.id == video_id, Photo.media_type == "video")
.first()
)
if not video:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Video {video_id} not found",
)
expire_web_playable_if_stale(video)
db.commit()
db.refresh(video)
playable = resolve_valid_playable_path(video)
if not playable:
raise HTTPException(
status_code=getattr(status, "HTTP_425_TOO_EARLY", 425),
detail="Playback not ready. Call POST .../web-playback/prepare and poll status.",
)
return stream_web_playable_file(playable, request)