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 typing import List, Optional, Dict, Any
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
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 fastapi.responses import FileResponse, StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select, and_, or_, func, tuple_
|
from sqlalchemy import select, and_, or_, func, tuple_
|
||||||
@@ -774,33 +774,112 @@ async def get_thumbnail(
|
|||||||
# Direct file serving for development
|
# Direct file serving for development
|
||||||
return FileResponse(thumb_path, media_type='image/webp')
|
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")
|
@router.get("/{photo_id}/original")
|
||||||
async def get_original(
|
async def get_original(
|
||||||
photo_id: str,
|
photo_id: str,
|
||||||
|
request: Request,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user_media),
|
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)
|
photo = await _get_photo_with_share_fallback(photo_id, current_user, db)
|
||||||
|
|
||||||
if not os.path.exists(photo.filepath):
|
if not os.path.exists(photo.filepath):
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
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()
|
ext = Path(photo.filepath).suffix.lower()
|
||||||
inline_types = {
|
media_type = _INLINE_MEDIA_TYPES.get(ext, 'application/octet-stream')
|
||||||
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
file_size = os.path.getsize(photo.filepath)
|
||||||
'.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')
|
|
||||||
|
|
||||||
|
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(
|
return FileResponse(
|
||||||
photo.filepath,
|
photo.filepath,
|
||||||
filename=photo.filename if media_type == 'application/octet-stream' else None,
|
filename=photo.filename if media_type == 'application/octet-stream' else None,
|
||||||
media_type=media_type,
|
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