Add offline clip caching

This commit is contained in:
Codex
2026-04-23 13:55:15 -05:00
parent ec2f440c13
commit 51ac5b2060
20 changed files with 554 additions and 27 deletions

79
backend/app/http_cache.py Normal file
View 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,
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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: