feat: Enhance photo and video handling in admin frontend
Some checks failed
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
Some checks failed
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:
parent
5b8e22d9d1
commit
70923e0ecf
@ -167,6 +167,7 @@ export interface PhotoSearchResult {
|
||||
date_taken?: string
|
||||
date_added: string
|
||||
processed: boolean
|
||||
media_type?: string // "image" or "video"
|
||||
person_name?: string
|
||||
tags: string[]
|
||||
has_faces: boolean
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { PhotoSearchResult, photosApi } from '../api/photos'
|
||||
import { apiClient } from '../api/client'
|
||||
import videosApi from '../api/videos'
|
||||
|
||||
interface PhotoViewerProps {
|
||||
photos: PhotoSearchResult[]
|
||||
@ -46,29 +47,43 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
|
||||
const canGoPrev = currentIndex > 0
|
||||
const canGoNext = currentIndex < photos.length - 1
|
||||
|
||||
// Get photo URL
|
||||
const getPhotoUrl = (photoId: number) => {
|
||||
// Check if current photo is a video
|
||||
const isVideo = (photo: PhotoSearchResult) => {
|
||||
return photo.media_type === 'video'
|
||||
}
|
||||
|
||||
// Get photo/video URL
|
||||
const getPhotoUrl = (photoId: number, mediaType?: string) => {
|
||||
if (mediaType === 'video') {
|
||||
return videosApi.getVideoUrl(photoId)
|
||||
}
|
||||
return `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image`
|
||||
}
|
||||
|
||||
// Preload adjacent images
|
||||
// Preload adjacent images (skip videos)
|
||||
const preloadAdjacent = (index: number) => {
|
||||
// Preload next photo
|
||||
// Preload next photo (only if it's an image)
|
||||
if (index + 1 < photos.length) {
|
||||
const nextPhotoId = photos[index + 1].id
|
||||
if (!preloadedImages.current.has(nextPhotoId)) {
|
||||
const img = new Image()
|
||||
img.src = getPhotoUrl(nextPhotoId)
|
||||
preloadedImages.current.add(nextPhotoId)
|
||||
const nextPhoto = photos[index + 1]
|
||||
if (!isVideo(nextPhoto)) {
|
||||
const nextPhotoId = nextPhoto.id
|
||||
if (!preloadedImages.current.has(nextPhotoId)) {
|
||||
const img = new Image()
|
||||
img.src = getPhotoUrl(nextPhotoId, nextPhoto.media_type)
|
||||
preloadedImages.current.add(nextPhotoId)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Preload previous photo
|
||||
// Preload previous photo (only if it's an image)
|
||||
if (index - 1 >= 0) {
|
||||
const prevPhotoId = photos[index - 1].id
|
||||
if (!preloadedImages.current.has(prevPhotoId)) {
|
||||
const img = new Image()
|
||||
img.src = getPhotoUrl(prevPhotoId)
|
||||
preloadedImages.current.add(prevPhotoId)
|
||||
const prevPhoto = photos[index - 1]
|
||||
if (!isVideo(prevPhoto)) {
|
||||
const prevPhotoId = prevPhoto.id
|
||||
if (!preloadedImages.current.has(prevPhotoId)) {
|
||||
const img = new Image()
|
||||
img.src = getPhotoUrl(prevPhotoId, prevPhoto.media_type)
|
||||
preloadedImages.current.add(prevPhotoId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -258,7 +273,8 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
|
||||
return null
|
||||
}
|
||||
|
||||
const photoUrl = getPhotoUrl(currentPhoto.id)
|
||||
const photoUrl = getPhotoUrl(currentPhoto.id, currentPhoto.media_type)
|
||||
const currentIsVideo = isVideo(currentPhoto)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black z-[100] flex flex-col">
|
||||
@ -330,16 +346,16 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Image Area */}
|
||||
{/* Main Image/Video Area */}
|
||||
<div
|
||||
ref={imageContainerRef}
|
||||
className="flex-1 flex items-center justify-center relative overflow-hidden"
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
style={{ cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default' }}
|
||||
onWheel={currentIsVideo ? undefined : handleWheel}
|
||||
onMouseDown={currentIsVideo ? undefined : handleMouseDown}
|
||||
onMouseMove={currentIsVideo ? undefined : handleMouseMove}
|
||||
onMouseUp={currentIsVideo ? undefined : handleMouseUp}
|
||||
onMouseLeave={currentIsVideo ? undefined : handleMouseUp}
|
||||
style={{ cursor: currentIsVideo ? 'default' : (zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default') }}
|
||||
>
|
||||
{imageLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black z-20">
|
||||
@ -348,9 +364,33 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
|
||||
)}
|
||||
{imageError ? (
|
||||
<div className="text-white text-center">
|
||||
<div className="text-lg mb-2">Failed to load image</div>
|
||||
<div className="text-lg mb-2">Failed to load {currentIsVideo ? 'video' : 'image'}</div>
|
||||
<div className="text-sm text-gray-400">{currentPhoto.path}</div>
|
||||
</div>
|
||||
) : currentIsVideo ? (
|
||||
<div className="relative h-full w-full max-h-[calc(90vh-80px)] max-w-full flex items-center justify-center">
|
||||
<video
|
||||
key={currentPhoto.id}
|
||||
src={photoUrl}
|
||||
className="object-contain w-full h-full max-w-full max-h-full"
|
||||
controls={true}
|
||||
controlsList="nodownload"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
}}
|
||||
onLoadedData={() => {
|
||||
setImageLoading(false)
|
||||
}}
|
||||
onError={() => {
|
||||
setImageLoading(false)
|
||||
setImageError(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
@ -373,37 +413,39 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<div className="absolute top-12 right-4 z-30 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={zoomIn}
|
||||
disabled={zoom >= ZOOM_MAX}
|
||||
className="px-3 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Zoom in (Ctrl/Cmd + Wheel)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<div className="px-3 py-1 bg-black bg-opacity-70 text-white rounded text-center text-xs">
|
||||
{Math.round(zoom * 100)}%
|
||||
</div>
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
disabled={zoom <= ZOOM_MIN}
|
||||
className="px-3 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Zoom out (Ctrl/Cmd + Wheel)"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
{zoom !== 1 && (
|
||||
{/* Zoom Controls (hidden for videos) */}
|
||||
{!currentIsVideo && (
|
||||
<div className="absolute top-12 right-4 z-30 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={resetZoom}
|
||||
className="px-3 py-1 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded text-xs"
|
||||
title="Reset zoom"
|
||||
onClick={zoomIn}
|
||||
disabled={zoom >= ZOOM_MAX}
|
||||
className="px-3 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Zoom in (Ctrl/Cmd + Wheel)"
|
||||
>
|
||||
Reset
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3 py-1 bg-black bg-opacity-70 text-white rounded text-center text-xs">
|
||||
{Math.round(zoom * 100)}%
|
||||
</div>
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
disabled={zoom <= ZOOM_MIN}
|
||||
className="px-3 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Zoom out (Ctrl/Cmd + Wheel)"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
{zoom !== 1 && (
|
||||
<button
|
||||
onClick={resetZoom}
|
||||
className="px-3 py-1 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded text-xs"
|
||||
title="Reset zoom"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<button
|
||||
|
||||
@ -680,8 +680,16 @@ export default function Search() {
|
||||
.join(', ')
|
||||
}, [selectedTagIds, allPhotoTags])
|
||||
|
||||
const openPhoto = (photoId: number) => {
|
||||
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image`
|
||||
const openPhoto = (photoId: number, mediaType?: string) => {
|
||||
const isVideo = mediaType === 'video'
|
||||
let photoUrl: string
|
||||
if (isVideo) {
|
||||
// Use video endpoint for videos
|
||||
photoUrl = `${apiClient.defaults.baseURL}/api/v1/videos/${photoId}/video`
|
||||
} else {
|
||||
// Use image endpoint for images
|
||||
photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image`
|
||||
}
|
||||
window.open(photoUrl, '_blank')
|
||||
}
|
||||
|
||||
@ -1784,9 +1792,9 @@ export default function Search() {
|
||||
)}
|
||||
<td className="p-2">
|
||||
<button
|
||||
onClick={() => openPhoto(photo.id)}
|
||||
onClick={() => openPhoto(photo.id, photo.media_type)}
|
||||
className="text-blue-600 hover:underline cursor-pointer"
|
||||
title="Open photo"
|
||||
title={photo.media_type === 'video' ? 'Open video' : 'Open photo'}
|
||||
>
|
||||
{photo.path}
|
||||
</button>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -5,8 +5,8 @@ from __future__ import annotations
|
||||
from datetime import date
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from fastapi.responses import FileResponse, Response, StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.db.session import get_db
|
||||
@ -296,11 +296,13 @@ def get_video_thumbnail(
|
||||
@router.get("/{video_id}/video")
|
||||
def get_video_file(
|
||||
video_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> FileResponse:
|
||||
"""Serve video file for playback."""
|
||||
):
|
||||
"""Serve video file for playback with range request support."""
|
||||
import os
|
||||
import mimetypes
|
||||
from starlette.responses import FileResponse
|
||||
|
||||
# Verify video exists
|
||||
video = db.query(Photo).filter(
|
||||
@ -325,7 +327,89 @@ def get_video_file(
|
||||
if not media_type or not media_type.startswith('video/'):
|
||||
media_type = "video/mp4"
|
||||
|
||||
# Use FileResponse with range request support for video streaming
|
||||
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'):
|
||||
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()
|
||||
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,
|
||||
|
||||
@ -32,6 +32,7 @@ class PhotoSearchResult(BaseModel):
|
||||
date_taken: Optional[date] = None
|
||||
date_added: date
|
||||
processed: bool
|
||||
media_type: Optional[str] = "image" # "image" or "video"
|
||||
person_name: Optional[str] = None # For name search
|
||||
tags: List[str] = Field(default_factory=list) # All tags for the photo
|
||||
has_faces: bool = False
|
||||
|
||||
@ -10,9 +10,11 @@ from typing import Optional
|
||||
from PIL import Image
|
||||
|
||||
|
||||
# Cache directory for thumbnails (relative to project root)
|
||||
# Will be created in the same directory as the database
|
||||
THUMBNAIL_CACHE_DIR = Path(__file__).parent.parent.parent.parent / "data" / "thumbnails"
|
||||
# Cache directory for thumbnails (relative to project root).
|
||||
# NOTE: This file lives at: <repo>/backend/services/thumbnail_service.py
|
||||
# So project root is 2 levels up from: <repo>/backend/services/
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
THUMBNAIL_CACHE_DIR = PROJECT_ROOT / "data" / "thumbnails"
|
||||
THUMBNAIL_SIZE = (320, 240) # Width, Height
|
||||
THUMBNAIL_QUALITY = 85 # JPEG quality
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user