Compare commits

..

No commits in common. "2f640b7b8d97c1e00eef188a07e6104ea4d5ab3e" and "5b8e22d9d10019a79d6ca0388a4302c40ded63c2" have entirely different histories.

7 changed files with 71 additions and 296 deletions

View File

@ -167,7 +167,6 @@ 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

View File

@ -1,7 +1,6 @@
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[]
@ -47,43 +46,29 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
const canGoPrev = currentIndex > 0
const canGoNext = currentIndex < photos.length - 1
// 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)
}
// Get photo URL
const getPhotoUrl = (photoId: number) => {
return `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image`
}
// Preload adjacent images (skip videos)
// Preload adjacent images
const preloadAdjacent = (index: number) => {
// Preload next photo (only if it's an image)
// Preload next photo
if (index + 1 < photos.length) {
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)
}
const nextPhotoId = photos[index + 1].id
if (!preloadedImages.current.has(nextPhotoId)) {
const img = new Image()
img.src = getPhotoUrl(nextPhotoId)
preloadedImages.current.add(nextPhotoId)
}
}
// Preload previous photo (only if it's an image)
// Preload previous photo
if (index - 1 >= 0) {
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)
}
const prevPhotoId = photos[index - 1].id
if (!preloadedImages.current.has(prevPhotoId)) {
const img = new Image()
img.src = getPhotoUrl(prevPhotoId)
preloadedImages.current.add(prevPhotoId)
}
}
}
@ -273,8 +258,7 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
return null
}
const photoUrl = getPhotoUrl(currentPhoto.id, currentPhoto.media_type)
const currentIsVideo = isVideo(currentPhoto)
const photoUrl = getPhotoUrl(currentPhoto.id)
return (
<div className="fixed inset-0 bg-black z-[100] flex flex-col">
@ -346,16 +330,16 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
</div>
</div>
{/* Main Image/Video Area */}
{/* Main Image Area */}
<div
ref={imageContainerRef}
className="flex-1 flex items-center justify-center relative overflow-hidden"
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') }}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{ cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default' }}
>
{imageLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black z-20">
@ -364,33 +348,9 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
)}
{imageError ? (
<div className="text-white text-center">
<div className="text-lg mb-2">Failed to load {currentIsVideo ? 'video' : 'image'}</div>
<div className="text-lg mb-2">Failed to load 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={{
@ -413,39 +373,37 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
</div>
)}
{/* Zoom Controls (hidden for videos) */}
{!currentIsVideo && (
<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 && (
<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>
)}
{/* 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 && (
<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

View File

@ -680,16 +680,8 @@ export default function Search() {
.join(', ')
}, [selectedTagIds, allPhotoTags])
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`
}
const openPhoto = (photoId: number) => {
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image`
window.open(photoUrl, '_blank')
}
@ -1792,9 +1784,9 @@ export default function Search() {
)}
<td className="p-2">
<button
onClick={() => openPhoto(photo.id, photo.media_type)}
onClick={() => openPhoto(photo.id)}
className="text-blue-600 hover:underline cursor-pointer"
title={photo.media_type === 'video' ? 'Open video' : 'Open photo'}
title="Open photo"
>
{photo.path}
</button>

View File

@ -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, Request, UploadFile, status
from fastapi.responses import JSONResponse, FileResponse, Response
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.responses import JSONResponse, FileResponse
from typing import Annotated
from rq import Queue
from redis import Redis
@ -130,7 +130,6 @@ 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,
@ -159,7 +158,6 @@ 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,
@ -195,7 +193,6 @@ 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,
@ -217,7 +214,6 @@ 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,
@ -240,7 +236,6 @@ 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,
@ -264,7 +259,6 @@ 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,
@ -288,7 +282,6 @@ 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,
@ -317,7 +310,6 @@ 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,
@ -564,16 +556,11 @@ def get_photo(photo_id: int, db: Session = Depends(get_db)) -> PhotoResponse:
@router.get("/{photo_id}/image")
def get_photo_image(
photo_id: int,
request: Request,
db: Session = Depends(get_db)
):
"""Serve photo image or video file for display (not download)."""
def get_photo_image(photo_id: int, db: Session = Depends(get_db)) -> FileResponse:
"""Serve photo image 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:
@ -588,81 +575,7 @@ def get_photo_image(
detail=f"Photo file not found: {photo.path}",
)
# 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
# Determine media type from file extension
media_type, _ = mimetypes.guess_type(photo.path)
if not media_type or not media_type.startswith('image/'):
media_type = "image/jpeg"

View File

@ -5,8 +5,8 @@ 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, StreamingResponse
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from backend.db.session import get_db
@ -296,13 +296,11 @@ def get_video_thumbnail(
@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."""
) -> FileResponse:
"""Serve video file for playback."""
import os
import mimetypes
from starlette.responses import FileResponse
# Verify video exists
video = db.query(Photo).filter(
@ -327,89 +325,7 @@ def get_video_file(
if not media_type or not media_type.startswith('video/'):
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'):
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
# Use FileResponse with range request support for video streaming
response = FileResponse(
video.path,
media_type=media_type,

View File

@ -32,7 +32,6 @@ 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

View File

@ -10,11 +10,9 @@ from typing import Optional
from PIL import Image
# 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"
# 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"
THUMBNAIL_SIZE = (320, 240) # Width, Height
THUMBNAIL_QUALITY = 85 # JPEG quality