Add ETag and 10-minute cache for /api/media
This commit is contained in:
+43
-5
@@ -27,7 +27,7 @@ import aiofiles
|
|||||||
import msgspec
|
import msgspec
|
||||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, StreamingResponse
|
from fastapi.responses import FileResponse, Response, StreamingResponse
|
||||||
from fastapi_vue import Frontend
|
from fastapi_vue import Frontend
|
||||||
|
|
||||||
from mediahive.__main__ import DEVMODE
|
from mediahive.__main__ import DEVMODE
|
||||||
@@ -230,6 +230,32 @@ def _parse_range_header(range_header: str, file_size: int) -> tuple[int, int]:
|
|||||||
return start, min(end, file_size - 1)
|
return start, min(end, file_size - 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_file_etag(file_size: int, mtime_ns: int) -> str:
|
||||||
|
"""Build a weak ETag from file metadata for cache validation."""
|
||||||
|
return f'W/"{file_size:x}-{mtime_ns:x}"'
|
||||||
|
|
||||||
|
|
||||||
|
def _etag_matches_if_none_match(if_none_match: str | None, etag: str) -> bool:
|
||||||
|
"""Return True when the request If-None-Match header matches the resource ETag."""
|
||||||
|
if not if_none_match:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if if_none_match.strip() == "*":
|
||||||
|
return True
|
||||||
|
|
||||||
|
def normalize(value: str) -> str:
|
||||||
|
v = value.strip()
|
||||||
|
if v.startswith("W/"):
|
||||||
|
v = v[2:].strip()
|
||||||
|
return v
|
||||||
|
|
||||||
|
wanted = normalize(etag)
|
||||||
|
for candidate in if_none_match.split(","):
|
||||||
|
if normalize(candidate) == wanted:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _validate_root_paths(roots: dict[str, str]) -> dict[str, str]:
|
def _validate_root_paths(roots: dict[str, str]) -> dict[str, str]:
|
||||||
"""Validate root paths on the filesystem.
|
"""Validate root paths on the filesystem.
|
||||||
|
|
||||||
@@ -568,7 +594,19 @@ async def serve_media_file(root_id: str, file_path: str, request: Request):
|
|||||||
if not full_path.is_file():
|
if not full_path.is_file():
|
||||||
raise HTTPException(status_code=400, detail="Not a file")
|
raise HTTPException(status_code=400, detail="Not a file")
|
||||||
|
|
||||||
file_size = full_path.stat().st_size
|
file_stat = full_path.stat()
|
||||||
|
file_size = file_stat.st_size
|
||||||
|
etag = _build_file_etag(file_size, file_stat.st_mtime_ns)
|
||||||
|
cache_control = "public, max-age=600"
|
||||||
|
|
||||||
|
range_header = request.headers.get("range")
|
||||||
|
if not range_header and _etag_matches_if_none_match(
|
||||||
|
request.headers.get("if-none-match"), etag
|
||||||
|
):
|
||||||
|
return Response(
|
||||||
|
status_code=304,
|
||||||
|
headers={"Cache-Control": cache_control, "ETag": etag},
|
||||||
|
)
|
||||||
|
|
||||||
content_type, _ = mimetypes.guess_type(str(full_path))
|
content_type, _ = mimetypes.guess_type(str(full_path))
|
||||||
if content_type is None:
|
if content_type is None:
|
||||||
@@ -578,7 +616,7 @@ async def serve_media_file(root_id: str, file_path: str, request: Request):
|
|||||||
return FileResponse(
|
return FileResponse(
|
||||||
full_path,
|
full_path,
|
||||||
media_type=content_type,
|
media_type=content_type,
|
||||||
headers={"Cache-Control": "public, max-age=86400"},
|
headers={"Cache-Control": cache_control, "ETag": etag},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def stream_file(start: int, end: int):
|
async def stream_file(start: int, end: int):
|
||||||
@@ -593,11 +631,11 @@ async def serve_media_file(root_id: str, file_path: str, request: Request):
|
|||||||
yield chunk
|
yield chunk
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Cache-Control": "public, max-age=86400",
|
"Cache-Control": cache_control,
|
||||||
|
"ETag": etag,
|
||||||
"Accept-Ranges": "bytes",
|
"Accept-Ranges": "bytes",
|
||||||
}
|
}
|
||||||
|
|
||||||
range_header = request.headers.get("range")
|
|
||||||
if range_header:
|
if range_header:
|
||||||
start, end = _parse_range_header(range_header, file_size)
|
start, end = _parse_range_header(range_header, file_size)
|
||||||
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
||||||
|
|||||||
Reference in New Issue
Block a user