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:
@@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user