diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index 99aafe6..8320370 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -4,7 +4,7 @@ Photos API router from typing import List, Optional, Dict, Any from datetime import datetime, timezone from pathlib import Path -from fastapi import APIRouter, Depends, HTTPException, Query, Response +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from fastapi.responses import FileResponse, StreamingResponse from pydantic import BaseModel from sqlalchemy import select, and_, or_, func, tuple_ @@ -774,33 +774,112 @@ async def get_thumbnail( # Direct file serving for development return FileResponse(thumb_path, media_type='image/webp') +_INLINE_MEDIA_TYPES = { + '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', + '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif', + '.mp4': 'video/mp4', '.mov': 'video/quicktime', + '.webm': 'video/webm', '.mkv': 'video/x-matroska', + '.m4v': 'video/mp4', +} + +# How big each chunk we yield is when streaming a Range response. 1 MB +# strikes a balance between syscall count and memory residency. +_RANGE_CHUNK = 1024 * 1024 + + +def _parse_range(header: str, size: int) -> Optional[tuple[int, int]]: + """Parse a single-range `Range: bytes=START-END` header. + + Returns (start, end) inclusive on success, or None when the header + is malformed / multi-range (we don't bother with multipart). The + caller falls back to a 200 response in that case. + """ + if not header or not header.startswith("bytes="): + return None + spec = header[len("bytes="):] + if "," in spec: # multi-range; punt + return None + if "-" not in spec: + return None + start_s, end_s = spec.split("-", 1) + try: + if start_s == "": + # bytes=-N → last N bytes + n = int(end_s) + if n <= 0: + return None + start = max(0, size - n) + end = size - 1 + else: + start = int(start_s) + end = int(end_s) if end_s else size - 1 + except ValueError: + return None + if start < 0 or start >= size or end < start: + return None + end = min(end, size - 1) + return start, end + + @router.get("/{photo_id}/original") async def get_original( photo_id: str, + request: Request, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user_media), ): - """Serve original file (download for RAW, inline for web-safe formats)""" + """Serve the original file inline (web-safe formats) with HTTP Range + support so `