feat: Enhance photo and video handling in admin frontend
CI / skip-ci-check (pull_request) Successful in 7s
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 / lint-and-type-check (pull_request) Has been cancelled
CI / skip-ci-check (pull_request) Successful in 7s
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 / lint-and-type-check (pull_request) Has been cancelled
- Added media_type to PhotoSearchResult interface to distinguish between images and videos. - Updated PhotoViewer component to support video playback, including URL handling and preloading logic. - Modified openPhoto function in Search page to open videos correctly. - Enhanced backend API to serve video files with range request support for better streaming experience. These changes improve the user experience by allowing seamless viewing of both images and videos in the application.
This commit is contained in:
+92
-5
@@ -5,8 +5,8 @@ from __future__ import annotations
|
||||
from datetime import date, datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from fastapi.responses import JSONResponse, FileResponse
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status
|
||||
from fastapi.responses import JSONResponse, FileResponse, Response
|
||||
from typing import Annotated
|
||||
from rq import Queue
|
||||
from redis import Redis
|
||||
@@ -130,6 +130,7 @@ def search_photos(
|
||||
date_taken=photo.date_taken,
|
||||
date_added=date_added,
|
||||
processed=photo.processed,
|
||||
media_type=photo.media_type or "image",
|
||||
person_name=full_name,
|
||||
tags=tags,
|
||||
has_faces=face_count > 0,
|
||||
@@ -158,6 +159,7 @@ def search_photos(
|
||||
date_taken=photo.date_taken,
|
||||
date_added=date_added,
|
||||
processed=photo.processed,
|
||||
media_type=photo.media_type or "image",
|
||||
person_name=person_name_val,
|
||||
tags=tags,
|
||||
has_faces=face_count > 0,
|
||||
@@ -193,6 +195,7 @@ def search_photos(
|
||||
date_taken=photo.date_taken,
|
||||
date_added=date_added,
|
||||
processed=photo.processed,
|
||||
media_type=photo.media_type or "image",
|
||||
person_name=person_name_val,
|
||||
tags=tags,
|
||||
has_faces=face_count > 0,
|
||||
@@ -214,6 +217,7 @@ def search_photos(
|
||||
date_taken=photo.date_taken,
|
||||
date_added=date_added,
|
||||
processed=photo.processed,
|
||||
media_type=photo.media_type or "image",
|
||||
person_name=None,
|
||||
tags=tags,
|
||||
has_faces=False,
|
||||
@@ -236,6 +240,7 @@ def search_photos(
|
||||
date_taken=photo.date_taken,
|
||||
date_added=date_added,
|
||||
processed=photo.processed,
|
||||
media_type=photo.media_type or "image",
|
||||
person_name=person_name_val,
|
||||
tags=[],
|
||||
has_faces=face_count > 0,
|
||||
@@ -259,6 +264,7 @@ def search_photos(
|
||||
date_taken=photo.date_taken,
|
||||
date_added=date_added,
|
||||
processed=photo.processed,
|
||||
media_type=photo.media_type or "image",
|
||||
person_name=person_name_val,
|
||||
tags=tags,
|
||||
has_faces=face_count > 0,
|
||||
@@ -282,6 +288,7 @@ def search_photos(
|
||||
date_taken=photo.date_taken,
|
||||
date_added=date_added,
|
||||
processed=photo.processed,
|
||||
media_type=photo.media_type or "image",
|
||||
person_name=person_name_val,
|
||||
tags=tags,
|
||||
has_faces=face_count > 0,
|
||||
@@ -310,6 +317,7 @@ def search_photos(
|
||||
date_taken=photo.date_taken,
|
||||
date_added=date_added,
|
||||
processed=photo.processed,
|
||||
media_type=photo.media_type or "image",
|
||||
person_name=person_name_val,
|
||||
tags=tags,
|
||||
has_faces=face_count > 0,
|
||||
@@ -556,11 +564,16 @@ def get_photo(photo_id: int, db: Session = Depends(get_db)) -> PhotoResponse:
|
||||
|
||||
|
||||
@router.get("/{photo_id}/image")
|
||||
def get_photo_image(photo_id: int, db: Session = Depends(get_db)) -> FileResponse:
|
||||
"""Serve photo image file for display (not download)."""
|
||||
def get_photo_image(
|
||||
photo_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Serve photo image or video file for display (not download)."""
|
||||
import os
|
||||
import mimetypes
|
||||
from backend.db.models import Photo
|
||||
from starlette.responses import FileResponse
|
||||
|
||||
photo = db.query(Photo).filter(Photo.id == photo_id).first()
|
||||
if not photo:
|
||||
@@ -575,7 +588,81 @@ def get_photo_image(photo_id: int, db: Session = Depends(get_db)) -> FileRespons
|
||||
detail=f"Photo file not found: {photo.path}",
|
||||
)
|
||||
|
||||
# Determine media type from file extension
|
||||
# If it's a video, handle range requests for video streaming
|
||||
if photo.media_type == "video":
|
||||
media_type, _ = mimetypes.guess_type(photo.path)
|
||||
if not media_type or not media_type.startswith('video/'):
|
||||
media_type = "video/mp4"
|
||||
|
||||
file_size = os.path.getsize(photo.path)
|
||||
# Get range header - Starlette uses lowercase
|
||||
range_header = request.headers.get("range")
|
||||
|
||||
# Debug: log what we're getting (remove after debugging)
|
||||
if photo_id == 737: # Only for this specific video
|
||||
import json
|
||||
debug_info = {
|
||||
"range_header": range_header,
|
||||
"all_headers": dict(request.headers),
|
||||
"header_keys": list(request.headers.keys())
|
||||
}
|
||||
print(f"DEBUG photo 737: {json.dumps(debug_info, indent=2)}")
|
||||
|
||||
if range_header:
|
||||
try:
|
||||
# Parse range header: "bytes=start-end" or "bytes=start-" or "bytes=-suffix"
|
||||
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
|
||||
with open(photo.path, "rb") as f:
|
||||
f.seek(start)
|
||||
chunk = f.read(chunk_size)
|
||||
|
||||
return Response(
|
||||
content=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) as e:
|
||||
# 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(
|
||||
photo.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
|
||||
|
||||
# Determine media type from file extension for images
|
||||
media_type, _ = mimetypes.guess_type(photo.path)
|
||||
if not media_type or not media_type.startswith('image/'):
|
||||
media_type = "image/jpeg"
|
||||
|
||||
Reference in New Issue
Block a user