Squash merge feature/library-reorganization
This commit is contained in:
2
backend/app/__init__.py
Normal file
2
backend/app/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Walkup backend package."""
|
||||
|
||||
160
backend/app/auth.py
Normal file
160
backend/app/auth.py
Normal 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
48
backend/app/config.py
Normal 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
26
backend/app/database.py
Normal 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
30
backend/app/main.py
Normal 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
98
backend/app/models.py
Normal 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()
|
||||
2
backend/app/routes/__init__.py
Normal file
2
backend/app/routes/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""API routes."""
|
||||
|
||||
177
backend/app/routes/auth.py
Normal file
177
backend/app/routes/auth.py
Normal 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
170
backend/app/routes/games.py
Normal 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)
|
||||
9
backend/app/routes/health.py
Normal file
9
backend/app/routes/health.py
Normal 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
373
backend/app/routes/media.py
Normal 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)
|
||||
81
backend/app/routes/teamsnap.py
Normal file
81
backend/app/routes/teamsnap.py
Normal 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
136
backend/app/schemas.py
Normal 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
140
backend/app/storage.py
Normal 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()
|
||||
Reference in New Issue
Block a user