Add offline clip caching
This commit is contained in:
79
backend/app/http_cache.py
Normal file
79
backend/app/http_cache.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request, Response
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
|
||||
|
||||
def _strip_keys(value: Any, excluded_keys: set[str]) -> Any:
|
||||
if isinstance(value, Mapping):
|
||||
return {
|
||||
key: _strip_keys(item, excluded_keys)
|
||||
for key, item in value.items()
|
||||
if key not in excluded_keys
|
||||
}
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return [_strip_keys(item, excluded_keys) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def build_etag(payload: Any, *, exclude_keys: set[str] | None = None) -> str:
|
||||
encoded = jsonable_encoder(payload)
|
||||
if exclude_keys:
|
||||
encoded = _strip_keys(encoded, exclude_keys)
|
||||
serialized = json.dumps(encoded, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
||||
digest = hashlib.sha1(serialized.encode("utf-8")).hexdigest()
|
||||
return f'"{digest}"'
|
||||
|
||||
|
||||
def is_matching_etag(request: Request, etag: str) -> bool:
|
||||
header = request.headers.get("if-none-match")
|
||||
if not header:
|
||||
return False
|
||||
|
||||
for candidate in header.split(","):
|
||||
if candidate.strip() in {etag, "*"}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def apply_cache_headers(
|
||||
response: Response,
|
||||
*,
|
||||
cache_control: str,
|
||||
etag: str | None = None,
|
||||
last_modified: datetime | None = None,
|
||||
) -> None:
|
||||
response.headers["Cache-Control"] = cache_control
|
||||
response.headers["Vary"] = "Cookie, Authorization"
|
||||
if etag is not None:
|
||||
response.headers["ETag"] = etag
|
||||
if last_modified is not None:
|
||||
response.headers["Last-Modified"] = last_modified.strftime("%a, %d %b %Y %H:%M:%S GMT")
|
||||
|
||||
|
||||
def set_no_store(response: Response) -> None:
|
||||
apply_cache_headers(response, cache_control="no-store")
|
||||
|
||||
|
||||
def set_private_revalidate(response: Response, *, etag: str | None = None, last_modified: datetime | None = None) -> None:
|
||||
apply_cache_headers(
|
||||
response,
|
||||
cache_control="private, max-age=0, must-revalidate",
|
||||
etag=etag,
|
||||
last_modified=last_modified,
|
||||
)
|
||||
|
||||
|
||||
def set_public_immutable(response: Response, *, etag: str | None = None, last_modified: datetime | None = None) -> None:
|
||||
apply_cache_headers(
|
||||
response,
|
||||
cache_control="public, max-age=31536000, immutable",
|
||||
etag=etag,
|
||||
last_modified=last_modified,
|
||||
)
|
||||
@@ -23,6 +23,7 @@ from ..auth import (
|
||||
)
|
||||
from ..config import settings
|
||||
from ..database import get_db
|
||||
from ..http_cache import set_no_store
|
||||
from ..models import UserSession
|
||||
from .teamsnap import build_proxy_api_root
|
||||
from ..schemas import (
|
||||
@@ -47,6 +48,7 @@ def teamsnap_start(return_to: str | None = Query(default="/")) -> Response:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="TeamSnap is not configured")
|
||||
state = secrets.token_urlsafe(24)
|
||||
response = JSONResponse({"authorize_url": build_teamsnap_authorize_url(state), "state": state})
|
||||
set_no_store(response)
|
||||
response.set_cookie(
|
||||
settings.auth_return_cookie_name,
|
||||
normalize_return_to(return_to),
|
||||
@@ -73,13 +75,18 @@ async def teamsnap_callback(
|
||||
db.commit()
|
||||
redirect_target = normalize_return_to(request.cookies.get(settings.auth_return_cookie_name))
|
||||
redirect = RedirectResponse(url=redirect_target, status_code=status.HTTP_303_SEE_OTHER)
|
||||
set_no_store(redirect)
|
||||
set_session_cookie(redirect, session.session_token)
|
||||
redirect.delete_cookie(settings.auth_return_cookie_name)
|
||||
return redirect
|
||||
|
||||
|
||||
@router.get("/session", response_model=SessionResponse)
|
||||
def session_status(session: UserSession | None = Depends(get_current_session)) -> SessionResponse:
|
||||
def session_status(
|
||||
response: Response,
|
||||
session: UserSession | None = Depends(get_current_session),
|
||||
) -> SessionResponse:
|
||||
set_no_store(response)
|
||||
if session is None:
|
||||
return SessionResponse(authenticated=False)
|
||||
return SessionResponse(
|
||||
@@ -96,6 +103,7 @@ def session_status(session: UserSession | None = Depends(get_current_session)) -
|
||||
@router.post("/teamsnap/token", response_model=TeamSnapTokenResponse)
|
||||
async def teamsnap_token(
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: UserSession = Depends(require_session),
|
||||
db: Session = Depends(get_db),
|
||||
) -> TeamSnapTokenResponse:
|
||||
@@ -115,6 +123,7 @@ async def teamsnap_token(
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
|
||||
set_no_store(response)
|
||||
return TeamSnapTokenResponse(
|
||||
access_token=session.access_token,
|
||||
expires_at=session.token_expires_at,
|
||||
@@ -126,6 +135,7 @@ async def teamsnap_token(
|
||||
@router.post("/session/walkup", response_model=SessionResponse)
|
||||
def update_walkup_session_selection(
|
||||
payload: WalkupSessionSelectionUpdate,
|
||||
response: Response,
|
||||
session: UserSession = Depends(require_session),
|
||||
db: Session = Depends(get_db),
|
||||
) -> SessionResponse:
|
||||
@@ -137,6 +147,7 @@ def update_walkup_session_selection(
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
set_no_store(response)
|
||||
return SessionResponse(
|
||||
authenticated=True,
|
||||
provider=session.provider,
|
||||
@@ -156,6 +167,7 @@ def admin_login(payload: AdminLoginRequest, response: Response, db: Session = De
|
||||
db.add(session)
|
||||
db.commit()
|
||||
set_session_cookie(response, session.session_token)
|
||||
set_no_store(response)
|
||||
return SessionResponse(authenticated=True, provider="local", is_admin=True)
|
||||
|
||||
|
||||
@@ -169,9 +181,11 @@ def logout(
|
||||
db.delete(session)
|
||||
db.commit()
|
||||
clear_session_cookie(response)
|
||||
set_no_store(response)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/admin/check", response_model=SessionResponse)
|
||||
def admin_check(_: UserSession = Depends(require_admin)) -> SessionResponse:
|
||||
def admin_check(response: Response, _: UserSession = Depends(require_admin)) -> SessionResponse:
|
||||
set_no_store(response)
|
||||
return SessionResponse(authenticated=True, provider="local", is_admin=True)
|
||||
|
||||
@@ -3,11 +3,13 @@ from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import Request, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import require_session
|
||||
from ..database import get_db
|
||||
from ..http_cache import build_etag, is_matching_etag, set_private_revalidate
|
||||
from ..models import AudioClip, GameAssignment, UserSession
|
||||
from ..schemas import (
|
||||
GameAssignmentCreate,
|
||||
@@ -37,8 +39,20 @@ def assignment_to_response(assignment: GameAssignment) -> GameAssignmentResponse
|
||||
)
|
||||
|
||||
|
||||
def prepare_conditional_response(
|
||||
request: Request,
|
||||
payload: object,
|
||||
*,
|
||||
exclude_keys: set[str] | None = None,
|
||||
) -> tuple[str, bool]:
|
||||
etag = build_etag(payload, exclude_keys=exclude_keys)
|
||||
return etag, is_matching_etag(request, etag)
|
||||
|
||||
|
||||
@router.get("/pins", response_model=list[GameAssignmentResponse])
|
||||
def list_pins(
|
||||
request: Request,
|
||||
response: Response,
|
||||
external_player_id: str | None = Query(default=None),
|
||||
session: UserSession = Depends(require_session),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -52,11 +66,18 @@ def list_pins(
|
||||
GameAssignment.external_player_id == player_id,
|
||||
)
|
||||
pins = db.scalars(query.order_by(GameAssignment.external_game_id.asc(), AudioClip.sort_order.asc())).all()
|
||||
return [assignment_to_response(assignment) for assignment in pins]
|
||||
payload = [assignment_to_response(assignment) for assignment in pins]
|
||||
etag, not_modified = prepare_conditional_response(request, payload)
|
||||
set_private_revalidate(response, etag=etag)
|
||||
if not_modified:
|
||||
return Response(status_code=304, headers=dict(response.headers))
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("/{external_game_id}/assignments", response_model=list[GameAssignmentResponse])
|
||||
def list_assignments(
|
||||
request: Request,
|
||||
response: Response,
|
||||
external_game_id: str,
|
||||
external_player_id: str | None = Query(default=None),
|
||||
_: UserSession = Depends(require_session),
|
||||
@@ -69,7 +90,12 @@ def list_assignments(
|
||||
if external_player_id:
|
||||
query = query.where(GameAssignment.external_player_id == external_player_id)
|
||||
assignments = db.scalars(query.order_by(AudioClip.sort_order.asc(), GameAssignment.updated_at.desc())).all()
|
||||
return [assignment_to_response(assignment) for assignment in assignments]
|
||||
payload = [assignment_to_response(assignment) for assignment in assignments]
|
||||
etag, not_modified = prepare_conditional_response(request, payload)
|
||||
set_private_revalidate(response, etag=etag)
|
||||
if not_modified:
|
||||
return Response(status_code=304, headers=dict(response.headers))
|
||||
return payload
|
||||
|
||||
|
||||
@router.post("/{external_game_id}/assignments", response_model=GameAssignmentResponse)
|
||||
@@ -136,6 +162,8 @@ def delete_assignment(
|
||||
|
||||
@router.get("/{external_game_id}/prep", response_model=GamePrepResponse)
|
||||
def prepare_game(
|
||||
request: Request,
|
||||
response: Response,
|
||||
external_game_id: str,
|
||||
_: UserSession = Depends(require_session),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -147,9 +175,14 @@ def prepare_game(
|
||||
.order_by(AudioClip.sort_order.asc(), GameAssignment.updated_at.desc())
|
||||
).all()
|
||||
external_team_id = assignments[0].external_team_id if assignments else ""
|
||||
return GamePrepResponse(
|
||||
payload = GamePrepResponse(
|
||||
external_game_id=external_game_id,
|
||||
external_team_id=external_team_id,
|
||||
prepared_at=datetime.now(timezone.utc),
|
||||
assignments=[assignment_to_response(assignment) for assignment in assignments],
|
||||
)
|
||||
etag, not_modified = prepare_conditional_response(request, payload, exclude_keys={"prepared_at"})
|
||||
set_private_revalidate(response, etag=etag)
|
||||
if not_modified:
|
||||
return Response(status_code=304, headers=dict(response.headers))
|
||||
return payload
|
||||
|
||||
@@ -4,13 +4,14 @@ import secrets
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import require_session
|
||||
from ..database import get_db
|
||||
from ..http_cache import build_etag, is_matching_etag, set_no_store, set_private_revalidate, set_public_immutable
|
||||
from ..models import AudioAsset, AudioClip, GameAssignment, UserSession
|
||||
from ..schemas import (
|
||||
AudioAssetImportCreate,
|
||||
@@ -50,6 +51,16 @@ def clip_to_response(clip: AudioClip) -> AudioClipResponse:
|
||||
)
|
||||
|
||||
|
||||
def prepare_conditional_response(
|
||||
request: Request,
|
||||
payload: object,
|
||||
*,
|
||||
exclude_keys: set[str] | None = None,
|
||||
) -> tuple[str, bool]:
|
||||
etag = build_etag(payload, exclude_keys=exclude_keys)
|
||||
return etag, is_matching_etag(request, etag)
|
||||
|
||||
|
||||
def can_manage_asset(session: UserSession, asset: AudioAsset, owner_external_player_id: str | None = None) -> bool:
|
||||
if session.is_admin or asset.uploaded_by_session_id == session.id:
|
||||
return True
|
||||
@@ -211,6 +222,8 @@ def import_audio(
|
||||
|
||||
@router.get("/assets", response_model=list[AudioAssetResponse])
|
||||
def list_assets(
|
||||
request: Request,
|
||||
response: Response,
|
||||
external_team_id: str,
|
||||
owner_external_player_id: str | None = None,
|
||||
_: UserSession = Depends(require_session),
|
||||
@@ -220,7 +233,12 @@ def list_assets(
|
||||
if owner_external_player_id:
|
||||
query = query.where(AudioAsset.owner_external_player_id == owner_external_player_id)
|
||||
assets = db.scalars(query.order_by(AudioAsset.created_at.desc())).all()
|
||||
return [AudioAssetResponse.model_validate(asset, from_attributes=True) for asset in assets]
|
||||
payload = [AudioAssetResponse.model_validate(asset, from_attributes=True) for asset in assets]
|
||||
etag, not_modified = prepare_conditional_response(request, payload)
|
||||
set_private_revalidate(response, etag=etag)
|
||||
if not_modified:
|
||||
return Response(status_code=304, headers=dict(response.headers))
|
||||
return payload
|
||||
|
||||
|
||||
@router.delete("/assets/{asset_id}", status_code=204)
|
||||
@@ -363,6 +381,8 @@ def delete_clip(
|
||||
|
||||
@router.get("/clips", response_model=list[AudioClipResponse])
|
||||
def list_clips(
|
||||
request: Request,
|
||||
response: Response,
|
||||
external_team_id: str,
|
||||
owner_external_player_id: str | None = None,
|
||||
include_hidden: bool = False,
|
||||
@@ -380,7 +400,12 @@ def list_clips(
|
||||
if not include_hidden:
|
||||
query = query.where(AudioClip.hidden.is_(False))
|
||||
clips = db.scalars(query).all()
|
||||
return [clip_to_response(clip) for clip in clips]
|
||||
payload = [clip_to_response(clip) for clip in clips]
|
||||
etag, not_modified = prepare_conditional_response(request, payload)
|
||||
set_private_revalidate(response, etag=etag)
|
||||
if not_modified:
|
||||
return Response(status_code=304, headers=dict(response.headers))
|
||||
return payload
|
||||
|
||||
|
||||
@router.post("/clips/reorder", status_code=204)
|
||||
@@ -415,4 +440,16 @@ def media_file(relative_path: str) -> FileResponse:
|
||||
path = storage.absolute_path(relative_path)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
return FileResponse(path)
|
||||
stat = path.stat()
|
||||
if path.is_relative_to(storage.normalized_dir):
|
||||
etag = build_etag({"path": str(path.relative_to(storage.root)), "size": stat.st_size, "mtime_ns": stat.st_mtime_ns})
|
||||
response = FileResponse(
|
||||
path,
|
||||
stat_result=stat,
|
||||
)
|
||||
set_public_immutable(response, etag=etag)
|
||||
return response
|
||||
|
||||
response = FileResponse(path, stat_result=stat)
|
||||
set_no_store(response)
|
||||
return response
|
||||
|
||||
@@ -108,6 +108,112 @@ def test_teamsnap_token_returns_proxy_api_root() -> None:
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["api_root"] == "https://kif.local.ascorrea.com/api/teamsnap"
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
def test_session_and_clip_reads_use_cache_validators() -> None:
|
||||
login = client.post("/auth/admin/login", json={"username": "admin", "password": "admin"})
|
||||
assert login.status_code == 200
|
||||
|
||||
session_response = client.get("/auth/session")
|
||||
assert session_response.status_code == 200
|
||||
assert session_response.headers["cache-control"] == "no-store"
|
||||
|
||||
db = SessionLocal()
|
||||
asset = AudioAsset(
|
||||
external_team_id="team-cache",
|
||||
owner_external_player_id="player-cache",
|
||||
title="Cache Song",
|
||||
original_filename="cache-song.mp3",
|
||||
mime_type="audio/mpeg",
|
||||
size_bytes=123,
|
||||
storage_path="uploads/cache-song.mp3",
|
||||
)
|
||||
db.add(asset)
|
||||
db.flush()
|
||||
clip = AudioClip(
|
||||
asset_id=asset.id,
|
||||
label="Cache clip",
|
||||
start_ms=0,
|
||||
end_ms=10000,
|
||||
normalization_status="ready",
|
||||
normalized_path="normalized/cache-clip.mp3",
|
||||
)
|
||||
db.add(clip)
|
||||
db.commit()
|
||||
db.refresh(clip)
|
||||
db.close()
|
||||
|
||||
clips = client.get("/media/clips", params={"external_team_id": "team-cache", "owner_external_player_id": "player-cache"})
|
||||
assert clips.status_code == 200
|
||||
assert clips.headers["etag"]
|
||||
assert clips.headers["cache-control"] == "private, max-age=0, must-revalidate"
|
||||
assert clips.headers["vary"] == "Cookie, Authorization"
|
||||
|
||||
revalidated = client.get(
|
||||
"/media/clips",
|
||||
params={"external_team_id": "team-cache", "owner_external_player_id": "player-cache"},
|
||||
headers={"if-none-match": clips.headers["etag"]},
|
||||
)
|
||||
assert revalidated.status_code == 304
|
||||
|
||||
|
||||
def test_game_prep_uses_stable_etag_for_cached_assignments() -> None:
|
||||
login = client.post("/auth/admin/login", json={"username": "admin", "password": "admin"})
|
||||
assert login.status_code == 200
|
||||
|
||||
db = SessionLocal()
|
||||
asset = AudioAsset(
|
||||
external_team_id="team-prep",
|
||||
owner_external_player_id="player-prep",
|
||||
title="Prep Song",
|
||||
original_filename="prep-song.mp3",
|
||||
mime_type="audio/mpeg",
|
||||
size_bytes=123,
|
||||
storage_path="uploads/prep-song.mp3",
|
||||
)
|
||||
db.add(asset)
|
||||
db.flush()
|
||||
clip = AudioClip(
|
||||
asset_id=asset.id,
|
||||
label="Prep clip",
|
||||
start_ms=0,
|
||||
end_ms=10000,
|
||||
normalization_status="ready",
|
||||
normalized_path="normalized/prep-clip.mp3",
|
||||
)
|
||||
db.add(clip)
|
||||
db.flush()
|
||||
assignment = GameAssignment(
|
||||
external_team_id="team-prep",
|
||||
external_game_id="game-prep",
|
||||
external_player_id="player-prep",
|
||||
clip_id=clip.id,
|
||||
batting_slot=3,
|
||||
status="ready",
|
||||
)
|
||||
db.add(assignment)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
prep = client.get("/games/game-prep/prep")
|
||||
assert prep.status_code == 200
|
||||
assert prep.headers["etag"]
|
||||
|
||||
revalidated = client.get("/games/game-prep/prep", headers={"if-none-match": prep.headers["etag"]})
|
||||
assert revalidated.status_code == 304
|
||||
|
||||
|
||||
def test_normalized_media_files_are_cacheable() -> None:
|
||||
media_file = settings.media_root / "normalized" / "cacheable.mp3"
|
||||
media_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
media_file.write_bytes(make_test_wav_bytes())
|
||||
|
||||
response = client.get("/media/files/normalized/cacheable.mp3")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
assert response.headers["etag"]
|
||||
|
||||
|
||||
def test_walkup_session_selection_is_persisted_in_session() -> None:
|
||||
|
||||
Reference in New Issue
Block a user