CI / skip-ci-check (pull_request) Successful in 29s
CI / python-lint (pull_request) Successful in 31s
CI / docker-ci (pull_request) Successful in 32s
CI / secret-scan (pull_request) Successful in 37s
CI / e2e (pull_request) Successful in 4m32s
CI / viewer-unit (pull_request) Successful in 4m42s
CI / admin-unit (pull_request) Successful in 5m6s
Capture optional contact fields on identify/approve, add Select All and Approve Next 10 on User Identified Faces, and email admins when the pending queue grows (ADMIN_NOTIFY_EMAIL via existing noreply SMTP).
505 lines
16 KiB
Python
505 lines
16 KiB
Python
"""Video person identification endpoints."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
from typing import Annotated, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
from fastapi.responses import FileResponse, Response
|
|
from redis import Redis
|
|
from rq import Queue
|
|
from sqlalchemy.orm import Session
|
|
|
|
from backend.api.auth import get_current_user_with_id
|
|
from backend.db.models import Photo, User
|
|
from backend.db.session import get_db
|
|
from backend.schemas.videos import (
|
|
IdentifyVideoRequest,
|
|
IdentifyVideoResponse,
|
|
ListVideosResponse,
|
|
PersonInfo,
|
|
RemoveVideoPersonResponse,
|
|
VideoListItem,
|
|
VideoPeopleResponse,
|
|
VideoPersonInfo,
|
|
WebPlaybackPrepareResponse,
|
|
WebPlaybackStatusResponse,
|
|
)
|
|
from backend.services.thumbnail_service import get_video_thumbnail_path
|
|
from backend.services.video_service import (
|
|
get_video_people,
|
|
identify_person_in_video,
|
|
list_videos_for_identification,
|
|
remove_person_from_video,
|
|
)
|
|
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(
|
|
current_user: Annotated[dict, Depends(get_current_user_with_id)],
|
|
folder_path: Optional[str] = Query(None, description="Filter by folder path"),
|
|
date_from: Optional[str] = Query(None, description="Filter by date taken (from, YYYY-MM-DD)"),
|
|
date_to: Optional[str] = Query(None, description="Filter by date taken (to, YYYY-MM-DD)"),
|
|
has_people: Optional[bool] = Query(None, description="Filter videos with/without identified people"),
|
|
person_name: Optional[str] = Query(None, description="Filter videos containing person with this name"),
|
|
sort_by: str = Query("filename", description="Sort field: filename, date_taken, date_added"),
|
|
sort_dir: str = Query("asc", description="Sort direction: asc or desc"),
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(50, ge=1, le=200),
|
|
db: Session = Depends(get_db),
|
|
) -> ListVideosResponse:
|
|
"""List videos for person identification."""
|
|
# Parse date filters
|
|
date_from_parsed = None
|
|
date_to_parsed = None
|
|
if date_from:
|
|
try:
|
|
date_from_parsed = date.fromisoformat(date_from)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid date_from format: {date_from}. Use YYYY-MM-DD",
|
|
)
|
|
if date_to:
|
|
try:
|
|
date_to_parsed = date.fromisoformat(date_to)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid date_to format: {date_to}. Use YYYY-MM-DD",
|
|
)
|
|
|
|
# Validate sort parameters
|
|
if sort_by not in ["filename", "date_taken", "date_added"]:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid sort_by: {sort_by}. Must be filename, date_taken, or date_added",
|
|
)
|
|
if sort_dir not in ["asc", "desc"]:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid sort_dir: {sort_dir}. Must be asc or desc",
|
|
)
|
|
|
|
# Get videos
|
|
videos, total = list_videos_for_identification(
|
|
db=db,
|
|
folder_path=folder_path,
|
|
date_from=date_from_parsed,
|
|
date_to=date_to_parsed,
|
|
has_people=has_people,
|
|
person_name=person_name,
|
|
sort_by=sort_by,
|
|
sort_dir=sort_dir,
|
|
page=page,
|
|
page_size=page_size,
|
|
)
|
|
|
|
# Build response items
|
|
items = []
|
|
for video in videos:
|
|
# Get people for this video
|
|
people_data = get_video_people(db, video.id)
|
|
identified_people = []
|
|
for person, linkage in people_data:
|
|
identified_people.append(
|
|
PersonInfo(
|
|
id=person.id,
|
|
first_name=person.first_name,
|
|
last_name=person.last_name,
|
|
middle_name=person.middle_name,
|
|
maiden_name=person.maiden_name,
|
|
date_of_birth=person.date_of_birth,
|
|
)
|
|
)
|
|
|
|
# Convert date_added to date if it's datetime
|
|
date_added = video.date_added
|
|
if hasattr(date_added, "date"):
|
|
date_added = date_added.date()
|
|
|
|
items.append(
|
|
VideoListItem(
|
|
id=video.id,
|
|
filename=video.filename,
|
|
path=video.path,
|
|
date_taken=video.date_taken,
|
|
date_added=date_added,
|
|
identified_people=identified_people,
|
|
identified_people_count=len(identified_people),
|
|
)
|
|
)
|
|
|
|
return ListVideosResponse(items=items, page=page, page_size=page_size, total=total)
|
|
|
|
|
|
@router.get("/{video_id}/people", response_model=VideoPeopleResponse)
|
|
def get_video_people_endpoint(
|
|
video_id: int,
|
|
db: Session = Depends(get_db),
|
|
) -> VideoPeopleResponse:
|
|
"""Get all people identified in a video."""
|
|
# Verify video exists
|
|
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",
|
|
)
|
|
|
|
# Get people
|
|
people_data = get_video_people(db, video_id)
|
|
|
|
people = []
|
|
for person, linkage in people_data:
|
|
# Get username if identified_by_user_id exists
|
|
username = None
|
|
if linkage.identified_by_user_id:
|
|
user = db.query(User).filter(User.id == linkage.identified_by_user_id).first()
|
|
if user:
|
|
username = user.username
|
|
|
|
people.append(
|
|
VideoPersonInfo(
|
|
person_id=person.id,
|
|
first_name=person.first_name,
|
|
last_name=person.last_name,
|
|
middle_name=person.middle_name,
|
|
maiden_name=person.maiden_name,
|
|
date_of_birth=person.date_of_birth,
|
|
identified_by=username,
|
|
identified_date=linkage.created_date,
|
|
)
|
|
)
|
|
|
|
return VideoPeopleResponse(video_id=video_id, people=people)
|
|
|
|
|
|
@router.post("/{video_id}/identify", response_model=IdentifyVideoResponse)
|
|
def identify_person_in_video_endpoint(
|
|
video_id: int,
|
|
request: IdentifyVideoRequest,
|
|
current_user: Annotated[dict, Depends(get_current_user_with_id)],
|
|
db: Session = Depends(get_db),
|
|
) -> IdentifyVideoResponse:
|
|
"""Identify a person in a video."""
|
|
user_id = current_user.get("id")
|
|
|
|
try:
|
|
person, created_person = identify_person_in_video(
|
|
db=db,
|
|
video_id=video_id,
|
|
person_id=request.person_id,
|
|
first_name=request.first_name,
|
|
last_name=request.last_name,
|
|
middle_name=request.middle_name,
|
|
maiden_name=request.maiden_name,
|
|
date_of_birth=request.date_of_birth,
|
|
email=request.email,
|
|
phone=request.phone,
|
|
user_id=user_id,
|
|
)
|
|
|
|
message = (
|
|
f"Person '{person.first_name} {person.last_name}' identified in video"
|
|
if not created_person
|
|
else f"Created new person '{person.first_name} {person.last_name}' and identified in video"
|
|
)
|
|
|
|
return IdentifyVideoResponse(
|
|
video_id=video_id,
|
|
person_id=person.id,
|
|
created_person=created_person,
|
|
message=message,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(e),
|
|
)
|
|
|
|
|
|
@router.delete("/{video_id}/people/{person_id}", response_model=RemoveVideoPersonResponse)
|
|
def remove_person_from_video_endpoint(
|
|
video_id: int,
|
|
person_id: int,
|
|
current_user: Annotated[dict, Depends(get_current_user_with_id)],
|
|
db: Session = Depends(get_db),
|
|
) -> RemoveVideoPersonResponse:
|
|
"""Remove person identification from video."""
|
|
try:
|
|
removed = remove_person_from_video(
|
|
db=db,
|
|
video_id=video_id,
|
|
person_id=person_id,
|
|
)
|
|
|
|
if removed:
|
|
return RemoveVideoPersonResponse(
|
|
video_id=video_id,
|
|
person_id=person_id,
|
|
removed=True,
|
|
message=f"Person {person_id} removed from video {video_id}",
|
|
)
|
|
else:
|
|
return RemoveVideoPersonResponse(
|
|
video_id=video_id,
|
|
person_id=person_id,
|
|
removed=False,
|
|
message=f"Person {person_id} not found in video {video_id}",
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(e),
|
|
)
|
|
|
|
|
|
@router.get("/{video_id}/thumbnail")
|
|
def get_video_thumbnail(
|
|
video_id: int,
|
|
db: Session = Depends(get_db),
|
|
) -> FileResponse:
|
|
"""Get video thumbnail (generated on-demand and cached)."""
|
|
# Verify video exists
|
|
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",
|
|
)
|
|
|
|
# Generate or get cached thumbnail
|
|
thumbnail_path = get_video_thumbnail_path(video.path)
|
|
|
|
if not thumbnail_path or not thumbnail_path.exists():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to generate video thumbnail",
|
|
)
|
|
|
|
# Return thumbnail with caching headers
|
|
response = FileResponse(
|
|
str(thumbnail_path),
|
|
media_type="image/jpeg",
|
|
)
|
|
response.headers["Cache-Control"] = "public, max-age=86400" # Cache for 1 day
|
|
return response
|
|
|
|
|
|
@router.get("/{video_id}/video")
|
|
def get_video_file(
|
|
video_id: int,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Serve video file for playback with range request support."""
|
|
import mimetypes
|
|
import os
|
|
|
|
from starlette.responses import FileResponse
|
|
|
|
# Verify video exists
|
|
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",
|
|
)
|
|
|
|
if not os.path.exists(video.path):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Video file not found: {video.path}",
|
|
)
|
|
|
|
# Determine media type from file extension
|
|
media_type, _ = mimetypes.guess_type(video.path)
|
|
if not media_type or not media_type.startswith('video/'):
|
|
media_type = "video/mp4"
|
|
|
|
file_size = os.path.getsize(video.path)
|
|
range_header = request.headers.get("range")
|
|
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
|
|
)
|
|
break
|
|
|
|
if range_header:
|
|
try:
|
|
# Parse range header: "bytes=start-end"
|
|
range_match = range_header.replace("bytes=", "").split("-")
|
|
start_str = range_match[0].strip()
|
|
end_str = range_match[1].strip() if len(range_match) > 1 and range_match[1] else ""
|
|
|
|
start = int(start_str) if start_str else 0
|
|
end = int(end_str) if end_str else file_size - 1
|
|
|
|
# Validate range
|
|
if start < 0:
|
|
start = 0
|
|
if end >= file_size:
|
|
end = file_size - 1
|
|
if start > end:
|
|
return Response(
|
|
status_code=416,
|
|
headers={"Content-Range": f"bytes */{file_size}"}
|
|
)
|
|
|
|
# Read the requested chunk
|
|
chunk_size = end - start + 1
|
|
|
|
def generate_chunk():
|
|
with open(video.path, "rb") as f:
|
|
f.seek(start)
|
|
remaining = chunk_size
|
|
while remaining > 0:
|
|
chunk = f.read(min(8192, remaining))
|
|
if not chunk:
|
|
break
|
|
yield chunk
|
|
remaining -= len(chunk)
|
|
|
|
from fastapi.responses import StreamingResponse
|
|
return StreamingResponse(
|
|
generate_chunk(),
|
|
status_code=206,
|
|
headers={
|
|
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Length": str(chunk_size),
|
|
"Content-Type": media_type,
|
|
"Content-Disposition": "inline",
|
|
"Cache-Control": "public, max-age=3600",
|
|
},
|
|
media_type=media_type,
|
|
)
|
|
except (ValueError, IndexError):
|
|
# If range parsing fails, fall through to serve full file
|
|
pass
|
|
|
|
# No range request or parsing failed - serve full file with range support headers
|
|
response = FileResponse(
|
|
video.path,
|
|
media_type=media_type,
|
|
)
|
|
response.headers["Content-Disposition"] = "inline"
|
|
response.headers["Accept-Ranges"] = "bytes"
|
|
response.headers["Cache-Control"] = "public, max-age=3600"
|
|
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)
|
|
|
|
|
|
|
|
|
|
|