fix(original): support HTTP Range so <video> can play .mov etc

`GET /api/v1/photos/{id}/original` returned 200 with the full body for
every request, even ones with a Range header. Browsers refuse to play
<video> they can't seek and surface the failure as "format not
supported" — most visible on .mov / .mp4 over 5–10 MB.

Now parses `Range: bytes=START-END` (and bytes=-N for the tail), emits
206 with Content-Range, streams the slice in 1 MB chunks. Full body
responses advertise Accept-Ranges so the browser knows to retry with a
Range on the next request.

Single-range only — multipart/byteranges is rare in practice and not
worth the complexity.
This commit is contained in:
Claudio
2026-05-11 12:02:44 +02:00
parent 28738acb56
commit 18dce33fa3

View File

@@ -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 `<video>` can seek and stream.
Browsers refuse to play long `<video>` they can't seek — without
Accept-Ranges + 206 they surface the failure as "format not
supported" even when the codec itself is fine.
"""
photo = await _get_photo_with_share_fallback(photo_id, current_user, db)
if not os.path.exists(photo.filepath):
raise HTTPException(status_code=404, detail="File not found")
# Pick a media type the browser can render inline for web-safe formats
# so the loupe view and <video> tags work without forcing a download.
ext = Path(photo.filepath).suffix.lower()
inline_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',
}
media_type = inline_types.get(ext, 'application/octet-stream')
media_type = _INLINE_MEDIA_TYPES.get(ext, 'application/octet-stream')
file_size = os.path.getsize(photo.filepath)
return FileResponse(
photo.filepath,
filename=photo.filename if media_type == 'application/octet-stream' else None,
range_header = request.headers.get("range")
parsed = _parse_range(range_header, file_size) if range_header else None
if parsed is None:
# No Range header or malformed: full body, but advertise
# Accept-Ranges so the browser knows it can ask for one next.
return FileResponse(
photo.filepath,
filename=photo.filename if media_type == 'application/octet-stream' else None,
media_type=media_type,
headers={"Accept-Ranges": "bytes"},
)
start, end = parsed
length = end - start + 1
def _iter_range():
with open(photo.filepath, 'rb') as f:
f.seek(start)
remaining = length
while remaining > 0:
chunk = f.read(min(_RANGE_CHUNK, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
return StreamingResponse(
_iter_range(),
status_code=206,
media_type=media_type,
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Content-Length": str(length),
"Accept-Ranges": "bytes",
},
)