diff --git a/admin-frontend/src/api/photos.ts b/admin-frontend/src/api/photos.ts index 3d1aab4..8813558 100644 --- a/admin-frontend/src/api/photos.ts +++ b/admin-frontend/src/api/photos.ts @@ -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 diff --git a/admin-frontend/src/components/PhotoViewer.tsx b/admin-frontend/src/components/PhotoViewer.tsx index 25d535b..2b0df99 100644 --- a/admin-frontend/src/components/PhotoViewer.tsx +++ b/admin-frontend/src/components/PhotoViewer.tsx @@ -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 (
@@ -330,16 +346,16 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
- {/* Main Image Area */} + {/* Main Image/Video Area */}
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 && (
@@ -348,9 +364,33 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView )} {imageError ? (
-
Failed to load image
+
Failed to load {currentIsVideo ? 'video' : 'image'}
{currentPhoto.path}
+ ) : currentIsVideo ? ( +
+
) : (
)} - {/* Zoom Controls */} -
- -
- {Math.round(zoom * 100)}% -
- - {zoom !== 1 && ( + {/* Zoom Controls (hidden for videos) */} + {!currentIsVideo && ( +
- )} -
+
+ {Math.round(zoom * 100)}% +
+ + {zoom !== 1 && ( + + )} +
+ )} {/* Navigation Buttons */} diff --git a/backend/api/photos.py b/backend/api/photos.py index aca7d01..0e9faaa 100644 --- a/backend/api/photos.py +++ b/backend/api/photos.py @@ -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" diff --git a/backend/api/videos.py b/backend/api/videos.py index 55c7d7a..a921dd8 100644 --- a/backend/api/videos.py +++ b/backend/api/videos.py @@ -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, diff --git a/backend/schemas/search.py b/backend/schemas/search.py index 918f000..e88ebf8 100644 --- a/backend/schemas/search.py +++ b/backend/schemas/search.py @@ -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 diff --git a/backend/services/thumbnail_service.py b/backend/services/thumbnail_service.py index 016d4fd..58dcd3c 100644 --- a/backend/services/thumbnail_service.py +++ b/backend/services/thumbnail_service.py @@ -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: /backend/services/thumbnail_service.py +# So project root is 2 levels up from: /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