Merge pull request 'feat: Enhance photo and video handling in admin frontend' (#9) from fix/video-range-streaming into dev
All checks were successful
CI / skip-ci-check (pull_request) Successful in 6s
CI / lint-and-type-check (pull_request) Successful in 51s
CI / python-lint (pull_request) Successful in 33s
CI / test-backend (pull_request) Successful in 2m30s
CI / build (pull_request) Successful in 3m22s
CI / secret-scanning (pull_request) Successful in 15s
CI / dependency-scan (pull_request) Successful in 12s
CI / sast-scan (pull_request) Successful in 1m24s
CI / workflow-summary (pull_request) Successful in 6s
All checks were successful
CI / skip-ci-check (pull_request) Successful in 6s
CI / lint-and-type-check (pull_request) Successful in 51s
CI / python-lint (pull_request) Successful in 33s
CI / test-backend (pull_request) Successful in 2m30s
CI / build (pull_request) Successful in 3m22s
CI / secret-scanning (pull_request) Successful in 15s
CI / dependency-scan (pull_request) Successful in 12s
CI / sast-scan (pull_request) Successful in 1m24s
CI / workflow-summary (pull_request) Successful in 6s
Reviewed-on: #9
This commit is contained in:
commit
2f640b7b8d
@ -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