Squash merge feature/library-reorganization

This commit is contained in:
Codex
2026-04-22 06:46:23 -05:00
parent 7f4a4beb5a
commit fe2a04343c
72 changed files with 14520 additions and 0 deletions

2
backend/app/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Walkup backend package."""

160
backend/app/auth.py Normal file
View File

@@ -0,0 +1,160 @@
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from urllib.parse import urlencode
import httpx
from fastapi import Depends, HTTPException, Request, Response, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from .config import settings
from .database import get_db
from .models import UserSession
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def create_session_token() -> str:
return secrets.token_urlsafe(32)
def set_session_cookie(response: Response, session_token: str) -> None:
response.set_cookie(
settings.session_cookie_name,
session_token,
httponly=True,
secure=settings.session_cookie_secure,
samesite="lax",
max_age=60 * 60 * 24 * 14,
)
def clear_session_cookie(response: Response) -> None:
response.delete_cookie(settings.session_cookie_name)
def get_current_session(
request: Request,
db: Session = Depends(get_db),
required: bool = False,
) -> UserSession | None:
session_token = request.cookies.get(settings.session_cookie_name)
if not session_token:
if required:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return None
session = db.scalar(select(UserSession).where(UserSession.session_token == session_token))
if session is None and required:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid session")
return session
def require_session(session: UserSession | None = Depends(get_current_session)) -> UserSession:
if session is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return session
def require_admin(session: UserSession = Depends(require_session)) -> UserSession:
if not session.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return session
def build_teamsnap_authorize_url(state: str) -> str:
params = urlencode(
{
"client_id": settings.teamsnap_client_id,
"redirect_uri": settings.teamsnap_redirect_uri,
"response_type": "code",
"scope": settings.teamsnap_scope,
"state": state,
}
)
return f"{settings.teamsnap_auth_url}?{params}"
def _extract_collection_item(payload: dict) -> dict[str, object] | None:
items = payload.get("collection", {}).get("items", [])
if not items:
return None
values: dict[str, object] = {}
for field in items[0].get("data", []):
name = field.get("name")
if isinstance(name, str):
values[name] = field.get("value")
return values
async def fetch_teamsnap_user_id(access_token: str) -> str | None:
headers = {
"Accept": "application/vnd.collection+json",
"Authorization": f"Bearer {access_token}",
}
try:
async with httpx.AsyncClient(timeout=15.0) as client:
root_response = await client.get(settings.teamsnap_api_root, headers=headers)
root_response.raise_for_status()
queries = root_response.json().get("collection", {}).get("queries", [])
me_href = next((query.get("href") for query in queries if query.get("rel") == "me"), None)
if not isinstance(me_href, str) or not me_href:
return None
me_response = await client.get(me_href, headers=headers)
me_response.raise_for_status()
except httpx.HTTPError:
return None
me_item = _extract_collection_item(me_response.json())
if not me_item:
return None
user_id = me_item.get("id")
return str(user_id) if user_id is not None else None
async def exchange_code_for_token(code: str) -> dict:
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(
settings.teamsnap_token_url,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": settings.teamsnap_redirect_uri,
"client_id": settings.teamsnap_client_id,
"client_secret": settings.teamsnap_client_secret,
},
headers={"Accept": "application/json"},
)
if response.status_code >= 400:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="TeamSnap token exchange failed")
return response.json()
async def refresh_access_token(refresh_token: str) -> dict:
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(
settings.teamsnap_token_url,
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": settings.teamsnap_client_id,
"client_secret": settings.teamsnap_client_secret,
},
headers={"Accept": "application/json"},
)
if response.status_code >= 400:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="TeamSnap token refresh failed")
return response.json()
def update_session_tokens(session: UserSession, token_payload: dict) -> None:
session.access_token = token_payload.get("access_token")
session.refresh_token = token_payload.get("refresh_token", session.refresh_token)
expires_in = token_payload.get("expires_in")
session.token_expires_at = utcnow() + timedelta(seconds=int(expires_in)) if expires_in else None

48
backend/app/config.py Normal file
View File

@@ -0,0 +1,48 @@
from __future__ import annotations
from pathlib import Path
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_name: str = "Walkup API"
backend_host: str = "0.0.0.0"
backend_port: int = 8000
backend_cors_origins_raw: str = "https://kif.local.ascorrea.com"
session_cookie_name: str = "walkup_session"
session_cookie_secure: bool = False
auth_return_cookie_name: str = "walkup_auth_return_to"
session_secret: str = "change-me"
local_admin_username: str = "admin"
local_admin_password: str = "admin"
teamsnap_client_id: str = ""
teamsnap_client_secret: str = ""
teamsnap_client_id_file: Path | None = None
teamsnap_client_secret_file: Path | None = None
teamsnap_auth_url: str = "https://auth.teamsnap.com/oauth/authorize"
teamsnap_token_url: str = "https://auth.teamsnap.com/oauth/token"
teamsnap_api_root: str = "https://apiv3.teamsnap.com"
teamsnap_redirect_uri: str = "https://kif.local.ascorrea.com/api/auth/teamsnap/callback"
teamsnap_scope: str = "read"
media_root: Path = Path("./storage")
database_url: str = "sqlite+pysqlite:///./walkup.db"
@property
def backend_cors_origins(self) -> list[str]:
return [item.strip() for item in self.backend_cors_origins_raw.split(",") if item.strip()]
def _read_secret_file(path: Path | None) -> str:
if path is None or not path.exists():
return ""
return path.read_text(encoding="utf-8").strip()
settings = Settings()
settings.teamsnap_client_id = settings.teamsnap_client_id or _read_secret_file(settings.teamsnap_client_id_file)
settings.teamsnap_client_secret = settings.teamsnap_client_secret or _read_secret_file(settings.teamsnap_client_secret_file)

26
backend/app/database.py Normal file
View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from .config import settings
class Base(DeclarativeBase):
pass
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, future=True, connect_args=connect_args)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()

30
backend/app/main.py Normal file
View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .config import settings
from .database import Base, engine
from .routes.auth import router as auth_router
from .routes.games import router as games_router
from .routes.health import router as health_router
from .routes.media import router as media_router
from .routes.teamsnap import router as teamsnap_router
Base.metadata.create_all(bind=engine)
app = FastAPI(title=settings.app_name)
app.state.settings = settings
app.add_middleware(
CORSMiddleware,
allow_origins=settings.backend_cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health_router)
app.include_router(auth_router)
app.include_router(media_router)
app.include_router(games_router)
app.include_router(teamsnap_router)

98
backend/app/models.py Normal file
View File

@@ -0,0 +1,98 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class UserSession(Base):
__tablename__ = "user_sessions"
id: Mapped[int] = mapped_column(primary_key=True)
session_token: Mapped[str] = mapped_column(String(128), unique=True, index=True)
provider: Mapped[str] = mapped_column(String(32), default="teamsnap")
external_user_id: Mapped[str | None] = mapped_column(String(128), index=True)
external_team_id: Mapped[str | None] = mapped_column(String(128), index=True)
external_player_id: Mapped[str | None] = mapped_column(String(128), index=True)
access_token: Mapped[str | None] = mapped_column(Text())
refresh_token: Mapped[str | None] = mapped_column(Text())
token_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
class AudioAsset(Base):
__tablename__ = "audio_assets"
id: Mapped[int] = mapped_column(primary_key=True)
external_team_id: Mapped[str] = mapped_column(String(128), index=True)
owner_external_player_id: Mapped[str] = mapped_column(String(128), index=True)
uploaded_by_session_id: Mapped[int | None] = mapped_column(ForeignKey("user_sessions.id"))
title: Mapped[str] = mapped_column(String(255))
original_filename: Mapped[str] = mapped_column(String(255))
mime_type: Mapped[str] = mapped_column(String(128))
size_bytes: Mapped[int] = mapped_column(Integer)
storage_path: Mapped[str] = mapped_column(String(512))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
uploaded_by: Mapped[UserSession | None] = relationship()
clips: Mapped[list[AudioClip]] = relationship(back_populates="asset", cascade="all, delete-orphan")
class AudioClip(Base):
__tablename__ = "audio_clips"
id: Mapped[int] = mapped_column(primary_key=True)
asset_id: Mapped[int] = mapped_column(ForeignKey("audio_assets.id"), index=True)
label: Mapped[str] = mapped_column(String(255))
start_ms: Mapped[int] = mapped_column(Integer)
end_ms: Mapped[int] = mapped_column(Integer)
normalization_status: Mapped[str] = mapped_column(String(32), default="pending")
normalized_path: Mapped[str | None] = mapped_column(String(512))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
asset: Mapped[AudioAsset] = relationship(back_populates="clips")
class GameAssignment(Base):
__tablename__ = "game_assignments"
__table_args__ = (
UniqueConstraint("external_game_id", "external_player_id", "clip_id", name="uq_game_assignment_player_clip"),
)
id: Mapped[int] = mapped_column(primary_key=True)
external_team_id: Mapped[str] = mapped_column(String(128), index=True)
external_game_id: Mapped[str] = mapped_column(String(128), index=True)
external_player_id: Mapped[str] = mapped_column(String(128), index=True)
clip_id: Mapped[int] = mapped_column(ForeignKey("audio_clips.id"), index=True)
batting_slot: Mapped[int | None] = mapped_column(Integer)
status: Mapped[str] = mapped_column(String(32), default="ready")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
clip: Mapped[AudioClip] = relationship()
class PlaybackSession(Base):
__tablename__ = "playback_sessions"
id: Mapped[int] = mapped_column(primary_key=True)
external_team_id: Mapped[str] = mapped_column(String(128), index=True)
external_game_id: Mapped[str] = mapped_column(String(128), index=True)
operator_session_id: Mapped[int | None] = mapped_column(ForeignKey("user_sessions.id"))
current_assignment_id: Mapped[int | None] = mapped_column(ForeignKey("game_assignments.id"))
state: Mapped[str] = mapped_column(String(32), default="idle")
last_triggered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
operator_session: Mapped[UserSession | None] = relationship()
current_assignment: Mapped[GameAssignment | None] = relationship()

View File

@@ -0,0 +1,2 @@
"""API routes."""

177
backend/app/routes/auth.py Normal file
View File

@@ -0,0 +1,177 @@
from __future__ import annotations
import secrets
import time
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from fastapi.responses import JSONResponse, RedirectResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..auth import (
build_teamsnap_authorize_url,
clear_session_cookie,
create_session_token,
exchange_code_for_token,
fetch_teamsnap_user_id,
get_current_session,
refresh_access_token,
require_admin,
require_session,
set_session_cookie,
update_session_tokens,
)
from ..config import settings
from ..database import get_db
from ..models import UserSession
from .teamsnap import build_proxy_api_root
from ..schemas import (
AdminLoginRequest,
SessionResponse,
TeamSnapTokenResponse,
WalkupSessionSelectionUpdate,
)
router = APIRouter(prefix="/auth", tags=["auth"])
def normalize_return_to(return_to: str | None) -> str:
if not return_to or not return_to.startswith("/") or return_to.startswith("//"):
return "/"
return return_to
@router.get("/teamsnap/start")
def teamsnap_start(return_to: str | None = Query(default="/")) -> Response:
if not settings.teamsnap_client_id:
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})
response.set_cookie(
settings.auth_return_cookie_name,
normalize_return_to(return_to),
httponly=True,
secure=settings.session_cookie_secure,
samesite="lax",
max_age=60 * 10,
)
return response
@router.get("/teamsnap/callback")
async def teamsnap_callback(
request: Request,
code: str = Query(...),
db: Session = Depends(get_db),
) -> Response:
token_payload = await exchange_code_for_token(code)
session = UserSession(session_token=create_session_token(), provider="teamsnap")
update_session_tokens(session, token_payload)
if session.access_token:
session.external_user_id = await fetch_teamsnap_user_id(session.access_token)
db.add(session)
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_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:
if session is None:
return SessionResponse(authenticated=False)
return SessionResponse(
authenticated=True,
provider=session.provider,
is_admin=session.is_admin,
external_user_id=session.external_user_id,
external_team_id=session.external_team_id,
external_player_id=session.external_player_id,
token_expires_at=session.token_expires_at,
)
@router.post("/teamsnap/token", response_model=TeamSnapTokenResponse)
async def teamsnap_token(
request: Request,
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> TeamSnapTokenResponse:
if session.provider != "teamsnap":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Session is not TeamSnap-backed")
if not session.access_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing TeamSnap access token")
expires_soon = session.token_expires_at is None or session.token_expires_at.timestamp() <= (time.time() + 60)
if expires_soon and session.refresh_token:
token_payload = await refresh_access_token(session.refresh_token)
update_session_tokens(session, token_payload)
if not session.external_user_id and session.access_token:
session.external_user_id = await fetch_teamsnap_user_id(session.access_token)
db.add(session)
db.commit()
db.refresh(session)
return TeamSnapTokenResponse(
access_token=session.access_token,
expires_at=session.token_expires_at,
api_root=build_proxy_api_root(request),
auth_url=settings.teamsnap_auth_url.removesuffix("/oauth/authorize"),
)
@router.post("/session/walkup", response_model=SessionResponse)
def update_walkup_session_selection(
payload: WalkupSessionSelectionUpdate,
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> SessionResponse:
if session.provider != "teamsnap":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Session is not TeamSnap-backed")
session.external_team_id = payload.external_team_id
session.external_player_id = payload.external_player_id
db.add(session)
db.commit()
db.refresh(session)
return SessionResponse(
authenticated=True,
provider=session.provider,
is_admin=session.is_admin,
external_user_id=session.external_user_id,
external_team_id=session.external_team_id,
external_player_id=session.external_player_id,
token_expires_at=session.token_expires_at,
)
@router.post("/admin/login", response_model=SessionResponse)
def admin_login(payload: AdminLoginRequest, response: Response, db: Session = Depends(get_db)) -> SessionResponse:
if payload.username != settings.local_admin_username or payload.password != settings.local_admin_password:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
session = UserSession(session_token=create_session_token(), provider="local", is_admin=True)
db.add(session)
db.commit()
set_session_cookie(response, session.session_token)
return SessionResponse(authenticated=True, provider="local", is_admin=True)
@router.post("/logout")
def logout(
response: Response,
session: UserSession | None = Depends(get_current_session),
db: Session = Depends(get_db),
) -> dict[str, bool]:
if session is not None:
db.delete(session)
db.commit()
clear_session_cookie(response)
return {"ok": True}
@router.get("/admin/check", response_model=SessionResponse)
def admin_check(_: UserSession = Depends(require_admin)) -> SessionResponse:
return SessionResponse(authenticated=True, provider="local", is_admin=True)

170
backend/app/routes/games.py Normal file
View File

@@ -0,0 +1,170 @@
from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..auth import require_session
from ..database import get_db
from ..models import AudioClip, GameAssignment, PlaybackSession, UserSession
from ..schemas import (
GameAssignmentCreate,
GameAssignmentResponse,
GamePrepResponse,
PlaybackAction,
PlaybackSessionCreate,
PlaybackSessionResponse,
)
router = APIRouter(prefix="/games", tags=["games"])
def assignment_to_response(assignment: GameAssignment) -> GameAssignmentResponse:
normalized_url = f"/media/files/{assignment.clip.normalized_path}" if assignment.clip.normalized_path else None
return GameAssignmentResponse(
id=assignment.id,
external_team_id=assignment.external_team_id,
external_game_id=assignment.external_game_id,
external_player_id=assignment.external_player_id,
clip_id=assignment.clip_id,
clip_label=assignment.clip.label,
asset_title=assignment.clip.asset.title,
start_ms=assignment.clip.start_ms,
end_ms=assignment.clip.end_ms,
batting_slot=assignment.batting_slot,
status=assignment.status,
normalized_url=normalized_url,
updated_at=assignment.updated_at,
)
@router.get("/{external_game_id}/assignments", response_model=list[GameAssignmentResponse])
def list_assignments(
external_game_id: str,
external_player_id: str | None = Query(default=None),
_: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> list[GameAssignmentResponse]:
query = select(GameAssignment).where(GameAssignment.external_game_id == external_game_id)
if external_player_id:
query = query.where(GameAssignment.external_player_id == external_player_id)
assignments = db.scalars(query.order_by(GameAssignment.batting_slot, GameAssignment.updated_at.desc())).all()
return [assignment_to_response(assignment) for assignment in assignments]
@router.post("/{external_game_id}/assignments", response_model=GameAssignmentResponse)
def create_assignment(
external_game_id: str,
payload: GameAssignmentCreate,
_: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> GameAssignmentResponse:
clip = db.get(AudioClip, payload.clip_id)
if clip is None or clip.normalization_status != "ready":
raise HTTPException(status_code=422, detail="Clip is not ready")
if clip.asset.external_team_id != payload.external_team_id:
raise HTTPException(status_code=422, detail="Clip does not belong to this team")
if clip.asset.owner_external_player_id != payload.external_player_id:
raise HTTPException(status_code=403, detail="You can only attach clips owned by that player")
assignment = db.scalar(
select(GameAssignment).where(
GameAssignment.external_game_id == external_game_id,
GameAssignment.external_player_id == payload.external_player_id,
GameAssignment.clip_id == payload.clip_id,
)
)
if assignment is None:
assignment = GameAssignment(
external_team_id=payload.external_team_id,
external_game_id=external_game_id,
external_player_id=payload.external_player_id,
clip_id=payload.clip_id,
batting_slot=payload.batting_slot,
status=payload.status,
)
db.add(assignment)
else:
assignment.external_team_id = payload.external_team_id
assignment.clip_id = payload.clip_id
assignment.batting_slot = payload.batting_slot
assignment.status = payload.status
db.commit()
db.refresh(assignment)
return assignment_to_response(assignment)
@router.get("/{external_game_id}/prep", response_model=GamePrepResponse)
def prepare_game(
external_game_id: str,
_: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> GamePrepResponse:
assignments = db.scalars(
select(GameAssignment)
.where(GameAssignment.external_game_id == external_game_id)
.order_by(GameAssignment.batting_slot, GameAssignment.updated_at.desc())
).all()
external_team_id = assignments[0].external_team_id if assignments else ""
return 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],
)
@router.post("/{external_game_id}/operator/session", response_model=PlaybackSessionResponse)
def create_playback_session(
external_game_id: str,
payload: PlaybackSessionCreate,
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> PlaybackSessionResponse:
playback = PlaybackSession(
external_team_id=payload.external_team_id,
external_game_id=external_game_id,
operator_session_id=session.id,
state="idle",
)
db.add(playback)
db.commit()
db.refresh(playback)
return PlaybackSessionResponse.model_validate(playback, from_attributes=True)
@router.post("/{external_game_id}/operator/session/{playback_session_id}/trigger", response_model=PlaybackSessionResponse)
def trigger_playback(
external_game_id: str,
playback_session_id: int,
payload: PlaybackAction,
_: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> PlaybackSessionResponse:
playback = db.get(PlaybackSession, playback_session_id)
if playback is None or playback.external_game_id != external_game_id:
raise HTTPException(status_code=404, detail="Playback session not found")
if payload.assignment_id is None and payload.clip_id is None:
raise HTTPException(status_code=422, detail="Provide an assignment or clip to trigger")
if payload.assignment_id is not None:
assignment = db.get(GameAssignment, payload.assignment_id)
if assignment is None or assignment.external_game_id != external_game_id:
raise HTTPException(status_code=404, detail="Assignment not found")
playback.current_assignment_id = assignment.id
else:
clip = db.get(AudioClip, payload.clip_id)
if clip is None or clip.asset.external_team_id != playback.external_team_id:
raise HTTPException(status_code=404, detail="Clip not found")
if payload.external_player_id and clip.asset.owner_external_player_id != payload.external_player_id:
raise HTTPException(status_code=403, detail="Clip does not belong to that player")
playback.current_assignment_id = None
playback.state = payload.state
playback.last_triggered_at = datetime.now(timezone.utc)
db.commit()
db.refresh(playback)
return PlaybackSessionResponse.model_validate(playback, from_attributes=True)

View File

@@ -0,0 +1,9 @@
from fastapi import APIRouter
router = APIRouter(tags=["health"])
@router.get("/health")
def healthcheck() -> dict[str, str]:
return {"status": "ok"}

373
backend/app/routes/media.py Normal file
View File

@@ -0,0 +1,373 @@
from __future__ import annotations
import secrets
import shutil
from pathlib import Path
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy import delete, select, update
from sqlalchemy.orm import Session
from ..auth import require_session
from ..database import get_db
from ..models import AudioAsset, AudioClip, GameAssignment, PlaybackSession, UserSession
from ..schemas import (
AudioAssetImportCreate,
AudioAssetResponse,
AudioAssetUpdate,
AudioClipCreate,
AudioClipResponse,
AudioClipUpdate,
)
from ..storage import storage
router = APIRouter(prefix="/media", tags=["media"])
DEFAULT_CLIP_LENGTH_MS = 30_000
def clip_to_response(clip: AudioClip) -> AudioClipResponse:
normalized_url = f"/media/files/{clip.normalized_path}" if clip.normalized_path else None
waveform = storage.load_or_generate_waveform(clip.asset.storage_path)
return AudioClipResponse(
id=clip.id,
asset_id=clip.asset_id,
external_team_id=clip.asset.external_team_id,
owner_external_player_id=clip.asset.owner_external_player_id,
asset_title=clip.asset.title,
label=clip.label,
start_ms=clip.start_ms,
end_ms=clip.end_ms,
normalization_status=clip.normalization_status,
normalized_url=normalized_url,
waveform_duration_ms=waveform["duration_ms"] if waveform else None,
waveform_peaks=waveform["peaks"] if waveform else None,
created_at=clip.created_at,
)
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
return owner_external_player_id is not None and asset.owner_external_player_id == owner_external_player_id
def create_asset_with_default_clip(
*,
db: Session,
session: UserSession,
external_team_id: str,
owner_external_player_id: str,
title: str,
original_filename: str,
mime_type: str,
size_bytes: int,
storage_path: str,
) -> AudioAssetResponse:
asset = AudioAsset(
external_team_id=external_team_id,
owner_external_player_id=owner_external_player_id,
uploaded_by_session_id=session.id,
title=title,
original_filename=original_filename,
mime_type=mime_type,
size_bytes=size_bytes,
storage_path=storage_path,
)
db.add(asset)
db.flush()
clip = AudioClip(
asset_id=asset.id,
label=asset.title,
start_ms=0,
end_ms=DEFAULT_CLIP_LENGTH_MS,
normalization_status="processing",
)
db.add(clip)
db.flush()
normalized_name = f"clip-{clip.id}-{secrets.token_hex(6)}{Path(asset.storage_path).suffix or '.bin'}"
clip.normalized_path = storage.normalize_clip(asset.storage_path, normalized_name)
clip.normalization_status = "ready"
storage.generate_waveform(asset.storage_path)
db.commit()
db.refresh(asset)
return AudioAssetResponse.model_validate(asset, from_attributes=True)
def download_media_to_storage(url: str) -> tuple[str, int, str, str]:
try:
from yt_dlp import YoutubeDL
from yt_dlp.utils import DownloadError
except ImportError as exc: # pragma: no cover - guarded by dependency install
raise HTTPException(status_code=500, detail="yt-dlp is not installed") from exc
storage_name = f"{secrets.token_hex(16)}.%(ext)s"
outtmpl = str(storage.uploads_dir / storage_name)
options = {
"format": "bestaudio/best",
"noplaylist": True,
"quiet": True,
"no_warnings": True,
"outtmpl": outtmpl,
"restrictfilenames": True,
"extractor_args": {"youtube": {"player_client": ["android"]}},
}
node_path = shutil.which("node")
if node_path:
options["js_runtimes"] = {"node": {"path": node_path}}
try:
with YoutubeDL(options) as ydl:
info = ydl.extract_info(url, download=True)
downloaded_path = Path(ydl.prepare_filename(info))
except DownloadError as exc: # pragma: no cover - exercised via HTTP behavior
message = str(exc).strip() or "Could not download media from that URL"
raise HTTPException(status_code=422, detail=f"Could not download media from that URL: {message}") from exc
except Exception as exc: # pragma: no cover - exercised via HTTP behavior
message = str(exc).strip() or "Could not download media from that URL"
raise HTTPException(status_code=422, detail=f"Could not download media from that URL: {message}") from exc
if not downloaded_path.exists():
raise HTTPException(status_code=502, detail="Downloaded file was not created")
size_bytes = downloaded_path.stat().st_size
original_filename = downloaded_path.name
source_title = str(info.get("title") or downloaded_path.stem)
return str(downloaded_path.relative_to(storage.root)), size_bytes, original_filename, source_title
@router.post("/uploads", response_model=AudioAssetResponse)
async def upload_audio(
external_team_id: str = Form(...),
owner_external_player_id: str = Form(...),
title: str = Form(...),
file: UploadFile = File(...),
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> AudioAssetResponse:
extension = Path(file.filename or "upload.bin").suffix or ".bin"
storage_name = f"{secrets.token_hex(16)}{extension}"
relative_path, size = storage.save_upload(file, storage_name)
return create_asset_with_default_clip(
db=db,
session=session,
external_team_id=external_team_id,
owner_external_player_id=owner_external_player_id,
title=title,
original_filename=file.filename or storage_name,
mime_type=file.content_type or "application/octet-stream",
size_bytes=size,
storage_path=relative_path,
)
@router.post("/imports", response_model=AudioAssetResponse)
def import_audio(
payload: AudioAssetImportCreate,
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> AudioAssetResponse:
relative_path, size_bytes, original_filename, source_title = download_media_to_storage(payload.url)
title = payload.title.strip() if payload.title else ""
if not title:
title = source_title
return create_asset_with_default_clip(
db=db,
session=session,
external_team_id=payload.external_team_id,
owner_external_player_id=payload.owner_external_player_id,
title=title,
original_filename=original_filename,
mime_type="application/octet-stream",
size_bytes=size_bytes,
storage_path=relative_path,
)
@router.get("/assets", response_model=list[AudioAssetResponse])
def list_assets(
external_team_id: str,
owner_external_player_id: str | None = None,
_: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> list[AudioAssetResponse]:
query = select(AudioAsset).where(AudioAsset.external_team_id == external_team_id)
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]
@router.delete("/assets/{asset_id}", status_code=204)
def delete_asset(
asset_id: int,
owner_external_player_id: str | None = None,
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> None:
asset = db.get(AudioAsset, asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
if not can_manage_asset(session, asset, owner_external_player_id):
raise HTTPException(status_code=403, detail="You can only delete your own uploads")
clips = db.scalars(select(AudioClip).where(AudioClip.asset_id == asset.id)).all()
clip_ids = [clip.id for clip in clips]
if clip_ids:
assignment_ids = db.scalars(select(GameAssignment.id).where(GameAssignment.clip_id.in_(clip_ids))).all()
db.execute(delete(GameAssignment).where(GameAssignment.clip_id.in_(clip_ids)))
if assignment_ids:
db.execute(
update(PlaybackSession)
.where(PlaybackSession.current_assignment_id.in_(assignment_ids))
.values(current_assignment_id=None)
)
for clip in clips:
if clip.normalized_path:
storage.delete_relative_path(clip.normalized_path)
db.delete(clip)
storage.delete_relative_path(asset.storage_path)
db.delete(asset)
db.commit()
@router.patch("/assets/{asset_id}", response_model=AudioAssetResponse)
def update_asset(
asset_id: int,
payload: AudioAssetUpdate,
owner_external_player_id: str | None = None,
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> AudioAssetResponse:
asset = db.get(AudioAsset, asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
if not can_manage_asset(session, asset, owner_external_player_id):
raise HTTPException(status_code=403, detail="You can only update your own uploads")
title = payload.title.strip()
if not title:
raise HTTPException(status_code=422, detail="File name cannot be blank")
asset.title = title
db.commit()
db.refresh(asset)
return AudioAssetResponse.model_validate(asset, from_attributes=True)
@router.post("/clips", response_model=AudioClipResponse)
def create_clip(
payload: AudioClipCreate,
_: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> AudioClipResponse:
asset = db.get(AudioAsset, payload.asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
if asset.external_team_id != payload.external_team_id:
raise HTTPException(status_code=422, detail="Clip does not belong to this team")
if asset.owner_external_player_id != payload.owner_external_player_id:
raise HTTPException(status_code=403, detail="You can only create clips for that player")
if payload.end_ms <= payload.start_ms:
raise HTTPException(status_code=422, detail="Clip end must be greater than start")
clip = AudioClip(
asset_id=asset.id,
label=payload.label,
start_ms=payload.start_ms,
end_ms=payload.end_ms,
normalization_status="processing",
)
db.add(clip)
db.flush()
normalized_name = f"clip-{clip.id}-{secrets.token_hex(6)}{Path(asset.storage_path).suffix or '.bin'}"
clip.normalized_path = storage.normalize_clip(asset.storage_path, normalized_name)
clip.normalization_status = "ready"
db.commit()
db.refresh(clip)
return clip_to_response(clip)
@router.patch("/clips/{clip_id}", response_model=AudioClipResponse)
def update_clip(
clip_id: int,
payload: AudioClipUpdate,
owner_external_player_id: str | None = None,
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> AudioClipResponse:
clip = db.get(AudioClip, clip_id)
if clip is None:
raise HTTPException(status_code=404, detail="Clip not found")
if not can_manage_asset(session, clip.asset, owner_external_player_id):
raise HTTPException(status_code=403, detail="You can only update clips from your own uploads")
if payload.end_ms <= payload.start_ms:
raise HTTPException(status_code=422, detail="Clip end must be greater than start")
clip.label = payload.label or clip.label
clip.start_ms = payload.start_ms
clip.end_ms = payload.end_ms
db.commit()
db.refresh(clip)
return clip_to_response(clip)
@router.delete("/clips/{clip_id}", status_code=204)
def delete_clip(
clip_id: int,
owner_external_player_id: str | None = None,
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> None:
clip = db.get(AudioClip, clip_id)
if clip is None:
raise HTTPException(status_code=404, detail="Clip not found")
if not can_manage_asset(session, clip.asset, owner_external_player_id):
raise HTTPException(status_code=403, detail="You can only delete clips from your own uploads")
assignment_ids = db.scalars(select(GameAssignment.id).where(GameAssignment.clip_id == clip.id)).all()
db.execute(delete(GameAssignment).where(GameAssignment.clip_id == clip.id))
if assignment_ids:
db.execute(
update(PlaybackSession)
.where(PlaybackSession.current_assignment_id.in_(assignment_ids))
.values(current_assignment_id=None)
)
if clip.normalized_path:
storage.delete_relative_path(clip.normalized_path)
db.delete(clip)
db.commit()
@router.get("/clips", response_model=list[AudioClipResponse])
def list_clips(
external_team_id: str,
owner_external_player_id: str | None = None,
_: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> list[AudioClipResponse]:
query = (
select(AudioClip)
.join(AudioClip.asset)
.where(AudioAsset.external_team_id == external_team_id)
.order_by(AudioClip.created_at.desc())
)
if owner_external_player_id:
query = query.where(AudioAsset.owner_external_player_id == owner_external_player_id)
clips = db.scalars(query).all()
return [clip_to_response(clip) for clip in clips]
@router.get("/files/{relative_path:path}")
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)

View File

@@ -0,0 +1,81 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
import json
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from ..auth import require_session
from ..models import UserSession
router = APIRouter(prefix="/teamsnap", tags=["teamsnap"])
def build_proxy_api_root(request: Request) -> str:
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
host = request.headers.get("x-forwarded-host", request.headers.get("host", request.url.netloc))
return f"{scheme}://{host}/api/teamsnap"
def rewrite_teamsnap_urls(value: object, upstream_root: str, proxy_root: str) -> object:
if isinstance(value, str):
return value.replace(upstream_root, proxy_root)
if isinstance(value, Mapping):
return {key: rewrite_teamsnap_urls(item, upstream_root, proxy_root) for key, item in value.items()}
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return [rewrite_teamsnap_urls(item, upstream_root, proxy_root) for item in value]
return value
def build_upstream_url(request: Request, proxy_path: str) -> str:
base = request.app.state.settings.teamsnap_api_root.rstrip("/")
if not proxy_path:
return base
return f"{base}/{proxy_path.lstrip('/')}"
@router.api_route("/{proxy_path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
@router.api_route("", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
async def teamsnap_proxy(
request: Request,
session: UserSession = Depends(require_session),
proxy_path: str = "",
) -> Response:
if session.provider != "teamsnap":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Session is not TeamSnap-backed")
if not session.access_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing TeamSnap access token")
upstream_url = build_upstream_url(request, proxy_path)
outgoing_headers = {
"Accept": request.headers.get("accept", "application/vnd.collection+json"),
"Authorization": f"Bearer {session.access_token}",
}
if request.headers.get("content-type"):
outgoing_headers["Content-Type"] = request.headers["content-type"]
body = await request.body()
async with httpx.AsyncClient(timeout=30.0) as client:
upstream = await client.request(
request.method,
upstream_url,
params=request.query_params,
content=body or None,
headers=outgoing_headers,
)
content_type = upstream.headers.get("content-type", "")
if "json" not in content_type.lower():
response = Response(content=upstream.content, status_code=upstream.status_code)
if content_type:
response.headers["Content-Type"] = content_type
return response
proxy_root = build_proxy_api_root(request)
rewritten = rewrite_teamsnap_urls(upstream.json(), request.app.state.settings.teamsnap_api_root, proxy_root)
return Response(
content=json.dumps(rewritten),
status_code=upstream.status_code,
media_type=content_type or "application/json",
)

136
backend/app/schemas.py Normal file
View File

@@ -0,0 +1,136 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class SessionResponse(BaseModel):
authenticated: bool
provider: str | None = None
is_admin: bool = False
external_user_id: str | None = None
external_team_id: str | None = None
external_player_id: str | None = None
token_expires_at: datetime | None = None
class TeamSnapTokenResponse(BaseModel):
access_token: str
expires_at: datetime | None = None
api_root: str
auth_url: str
class WalkupSessionSelectionUpdate(BaseModel):
external_team_id: str = Field(min_length=1, max_length=128)
external_player_id: str = Field(min_length=1, max_length=128)
class AdminLoginRequest(BaseModel):
username: str
password: str
class AudioAssetResponse(BaseModel):
id: int
external_team_id: str
owner_external_player_id: str
title: str
original_filename: str
mime_type: str
size_bytes: int
created_at: datetime
class AudioAssetUpdate(BaseModel):
title: str = Field(min_length=1, max_length=255)
class AudioAssetImportCreate(BaseModel):
external_team_id: str
owner_external_player_id: str
url: str = Field(min_length=1)
title: str | None = Field(default=None, min_length=1, max_length=255)
class AudioClipCreate(BaseModel):
asset_id: int
external_team_id: str
owner_external_player_id: str
label: str = Field(min_length=1, max_length=255)
start_ms: int = Field(ge=0)
end_ms: int = Field(gt=0)
class AudioClipUpdate(BaseModel):
label: str | None = Field(default=None, min_length=1, max_length=255)
start_ms: int = Field(ge=0)
end_ms: int = Field(gt=0)
class AudioClipResponse(BaseModel):
id: int
asset_id: int
external_team_id: str
owner_external_player_id: str
asset_title: str
label: str
start_ms: int
end_ms: int
normalization_status: str
normalized_url: str | None
waveform_duration_ms: int | None = None
waveform_peaks: list[int] | None = None
created_at: datetime
class GameAssignmentCreate(BaseModel):
external_team_id: str
external_player_id: str
clip_id: int
batting_slot: int | None = Field(default=None, ge=1, le=99)
status: str = "ready"
class GameAssignmentResponse(BaseModel):
id: int
external_team_id: str
external_game_id: str
external_player_id: str
clip_id: int
clip_label: str
asset_title: str
start_ms: int
end_ms: int
batting_slot: int | None
status: str
normalized_url: str | None
updated_at: datetime
class GamePrepResponse(BaseModel):
external_game_id: str
external_team_id: str
prepared_at: datetime
assignments: list[GameAssignmentResponse]
class PlaybackSessionCreate(BaseModel):
external_team_id: str
class PlaybackAction(BaseModel):
assignment_id: int | None = None
clip_id: int | None = None
external_player_id: str | None = None
state: str = "playing"
class PlaybackSessionResponse(BaseModel):
id: int
external_team_id: str
external_game_id: str
current_assignment_id: int | None
state: str
last_triggered_at: datetime | None

140
backend/app/storage.py Normal file
View File

@@ -0,0 +1,140 @@
from __future__ import annotations
import json
import shutil
import subprocess
import sys
from array import array
from pathlib import Path
from fastapi import UploadFile
from .config import settings
WAVEFORM_PEAK_COUNT = 1024
class MediaStorage:
def __init__(self) -> None:
self._ensure_directories()
@property
def root(self) -> Path:
return settings.media_root
@property
def uploads_dir(self) -> Path:
return self.root / "uploads"
@property
def normalized_dir(self) -> Path:
return self.root / "normalized"
def waveform_sidecar_path(self, relative_path: str) -> Path:
path = self.absolute_path(relative_path)
return path.with_name(f"{path.name}.waveform.json")
def _ensure_directories(self) -> None:
self.uploads_dir.mkdir(parents=True, exist_ok=True)
self.normalized_dir.mkdir(parents=True, exist_ok=True)
def save_upload(self, upload: UploadFile, destination_name: str) -> tuple[str, int]:
self._ensure_directories()
destination = self.uploads_dir / destination_name
size = 0
with destination.open("wb") as output:
while chunk := upload.file.read(1024 * 1024):
size += len(chunk)
output.write(chunk)
return str(destination.relative_to(self.root)), size
def normalize_clip(self, source_relative_path: str, clip_name: str) -> str:
self._ensure_directories()
source = self.root / source_relative_path
destination = self.normalized_dir / clip_name
shutil.copyfile(source, destination)
return str(destination.relative_to(self.root))
def generate_waveform(self, source_relative_path: str, bins: int = WAVEFORM_PEAK_COUNT) -> dict[str, int | list[int]]:
self._ensure_directories()
source = self.root / source_relative_path
if not source.exists():
raise FileNotFoundError(source)
ffmpeg_path = shutil.which("ffmpeg")
if not ffmpeg_path:
raise RuntimeError("ffmpeg is not installed")
completed = subprocess.run(
[
ffmpeg_path,
"-v",
"error",
"-i",
str(source),
"-ac",
"1",
"-ar",
"8000",
"-f",
"s16le",
"pipe:1",
],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
pcm = completed.stdout
samples = array("h")
samples.frombytes(pcm)
if sys.byteorder != "little":
samples.byteswap()
sample_count = len(samples)
if sample_count == 0:
peaks = [0 for _ in range(bins)]
duration_ms = 0
else:
peaks = []
for index in range(bins):
start = index * sample_count // bins
end = (index + 1) * sample_count // bins
if end <= start:
end = min(sample_count, start + 1)
segment = samples[start:end]
peak = max((abs(value) for value in segment), default=0)
peaks.append(round((peak / 32767) * 100))
duration_ms = round((sample_count / 8000) * 1000)
waveform = {"duration_ms": duration_ms, "peaks": peaks}
sidecar_path = self.waveform_sidecar_path(source_relative_path)
sidecar_path.parent.mkdir(parents=True, exist_ok=True)
sidecar_path.write_text(json.dumps(waveform), encoding="utf-8")
return waveform
def load_waveform(self, relative_path: str) -> dict[str, int | list[int]] | None:
sidecar_path = self.waveform_sidecar_path(relative_path)
if not sidecar_path.exists():
return None
return json.loads(sidecar_path.read_text(encoding="utf-8"))
def load_or_generate_waveform(self, relative_path: str) -> dict[str, int | list[int]] | None:
existing = self.load_waveform(relative_path)
if existing is not None and len(existing.get("peaks", [])) == WAVEFORM_PEAK_COUNT:
return existing
source = self.absolute_path(relative_path)
if not source.exists():
return None
return self.generate_waveform(relative_path)
def absolute_path(self, relative_path: str) -> Path:
return self.root / relative_path
def delete_relative_path(self, relative_path: str) -> None:
path = self.absolute_path(relative_path)
path.unlink(missing_ok=True)
self.waveform_sidecar_path(relative_path).unlink(missing_ok=True)
storage = MediaStorage()