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
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:
+98
-23
@@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user