Fix backend imports and clip pinning flow

This commit is contained in:
Codex
2026-04-22 07:48:12 -05:00
parent 45c2b46304
commit ec73156966
12 changed files with 736 additions and 156 deletions

View File

@@ -7,6 +7,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg nodejs &
COPY pyproject.toml /app/pyproject.toml
COPY requirements.txt requirements-dev.txt /app/
COPY app /app/app
RUN pip install --no-cache-dir -r requirements-dev.txt
RUN pip install --no-cache-dir -e .[dev]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -55,6 +55,7 @@ class AudioClip(Base):
label: Mapped[str] = mapped_column(String(255))
start_ms: Mapped[int] = mapped_column(Integer)
end_ms: Mapped[int] = mapped_column(Integer)
sort_order: Mapped[int] = mapped_column(Integer, default=0, index=True)
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)
@@ -65,7 +66,7 @@ class AudioClip(Base):
class GameAssignment(Base):
__tablename__ = "game_assignments"
__table_args__ = (
UniqueConstraint("external_game_id", "external_player_id", "clip_id", name="uq_game_assignment_player_clip"),
UniqueConstraint("external_game_id", "clip_id", name="uq_game_assignment_game_clip"),
)
id: Mapped[int] = mapped_column(primary_key=True)

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.orm import Session
from ..auth import require_session
@@ -40,6 +40,24 @@ def assignment_to_response(assignment: GameAssignment) -> GameAssignmentResponse
)
@router.get("/pins", response_model=list[GameAssignmentResponse])
def list_pins(
external_player_id: str | None = Query(default=None),
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> list[GameAssignmentResponse]:
player_id = external_player_id or session.external_player_id
if not player_id or not session.external_team_id:
raise HTTPException(status_code=422, detail="Provide a player to list pins")
query = select(GameAssignment).join(GameAssignment.clip).where(
GameAssignment.external_team_id == session.external_team_id,
GameAssignment.external_player_id == player_id,
)
pins = db.scalars(query.order_by(GameAssignment.external_game_id.asc(), AudioClip.sort_order.asc())).all()
return [assignment_to_response(assignment) for assignment in pins]
@router.get("/{external_game_id}/assignments", response_model=list[GameAssignmentResponse])
def list_assignments(
external_game_id: str,
@@ -47,10 +65,10 @@ def list_assignments(
_: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> list[GameAssignmentResponse]:
query = select(GameAssignment).where(GameAssignment.external_game_id == external_game_id)
query = select(GameAssignment).join(GameAssignment.clip).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()
assignments = db.scalars(query.order_by(AudioClip.sort_order.asc(), GameAssignment.updated_at.desc())).all()
return [assignment_to_response(assignment) for assignment in assignments]
@@ -67,12 +85,11 @@ def create_assignment(
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")
raise HTTPException(status_code=403, detail="You can only pin 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,
)
)
@@ -88,6 +105,7 @@ def create_assignment(
db.add(assignment)
else:
assignment.external_team_id = payload.external_team_id
assignment.external_player_id = payload.external_player_id
assignment.clip_id = payload.clip_id
assignment.batting_slot = payload.batting_slot
assignment.status = payload.status
@@ -96,6 +114,25 @@ def create_assignment(
return assignment_to_response(assignment)
@router.delete("/{external_game_id}/assignments/{assignment_id}", status_code=204)
def delete_assignment(
external_game_id: str,
assignment_id: int,
external_player_id: str | None = Query(default=None),
_: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> None:
assignment = db.get(GameAssignment, assignment_id)
if assignment is None or assignment.external_game_id != external_game_id:
raise HTTPException(status_code=404, detail="Pin not found")
if external_player_id is not None and assignment.external_player_id != external_player_id:
raise HTTPException(status_code=403, detail="Pin does not belong to that player")
db.execute(update(PlaybackSession).where(PlaybackSession.current_assignment_id == assignment.id).values(current_assignment_id=None))
db.delete(assignment)
db.commit()
@router.get("/{external_game_id}/prep", response_model=GamePrepResponse)
def prepare_game(
external_game_id: str,
@@ -104,8 +141,9 @@ def prepare_game(
) -> GamePrepResponse:
assignments = db.scalars(
select(GameAssignment)
.join(GameAssignment.clip)
.where(GameAssignment.external_game_id == external_game_id)
.order_by(GameAssignment.batting_slot, GameAssignment.updated_at.desc())
.order_by(AudioClip.sort_order.asc(), GameAssignment.updated_at.desc())
).all()
external_team_id = assignments[0].external_team_id if assignments else ""
return GamePrepResponse(
@@ -148,12 +186,12 @@ def trigger_playback(
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")
raise HTTPException(status_code=422, detail="Provide a pin 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")
raise HTTPException(status_code=404, detail="Pin not found")
playback.current_assignment_id = assignment.id
else:
clip = db.get(AudioClip, payload.clip_id)

View File

@@ -6,7 +6,7 @@ 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 import delete, func, select, update
from sqlalchemy.orm import Session
from ..auth import require_session
@@ -19,6 +19,7 @@ from ..schemas import (
AudioClipCreate,
AudioClipResponse,
AudioClipUpdate,
AudioClipReorder,
)
from ..storage import storage
@@ -39,6 +40,7 @@ def clip_to_response(clip: AudioClip) -> AudioClipResponse:
label=clip.label,
start_ms=clip.start_ms,
end_ms=clip.end_ms,
sort_order=clip.sort_order,
normalization_status=clip.normalization_status,
normalized_url=normalized_url,
waveform_duration_ms=waveform["duration_ms"] if waveform else None,
@@ -53,6 +55,18 @@ def can_manage_asset(session: UserSession, asset: AudioAsset, owner_external_pla
return owner_external_player_id is not None and asset.owner_external_player_id == owner_external_player_id
def next_clip_sort_order(db: Session, *, external_team_id: str, owner_external_player_id: str) -> int:
highest_sort_order = db.scalar(
select(func.max(AudioClip.sort_order))
.join(AudioClip.asset)
.where(
AudioAsset.external_team_id == external_team_id,
AudioAsset.owner_external_player_id == owner_external_player_id,
)
)
return highest_sort_order + 1 if highest_sort_order is not None else 0
def create_asset_with_default_clip(
*,
db: Session,
@@ -83,6 +97,11 @@ def create_asset_with_default_clip(
label=asset.title,
start_ms=0,
end_ms=DEFAULT_CLIP_LENGTH_MS,
sort_order=next_clip_sort_order(
db,
external_team_id=external_team_id,
owner_external_player_id=owner_external_player_id,
),
normalization_status="processing",
)
db.add(clip)
@@ -282,6 +301,11 @@ def create_clip(
label=payload.label,
start_ms=payload.start_ms,
end_ms=payload.end_ms,
sort_order=next_clip_sort_order(
db,
external_team_id=payload.external_team_id,
owner_external_player_id=payload.owner_external_player_id,
),
normalization_status="processing",
)
db.add(clip)
@@ -314,6 +338,8 @@ def update_clip(
clip.label = payload.label or clip.label
clip.start_ms = payload.start_ms
clip.end_ms = payload.end_ms
if payload.sort_order is not None:
clip.sort_order = payload.sort_order
db.commit()
db.refresh(clip)
return clip_to_response(clip)
@@ -357,7 +383,7 @@ def list_clips(
select(AudioClip)
.join(AudioClip.asset)
.where(AudioAsset.external_team_id == external_team_id)
.order_by(AudioClip.created_at.desc())
.order_by(AudioClip.sort_order.asc(), AudioClip.created_at.desc())
)
if owner_external_player_id:
query = query.where(AudioAsset.owner_external_player_id == owner_external_player_id)
@@ -365,6 +391,33 @@ def list_clips(
return [clip_to_response(clip) for clip in clips]
@router.post("/clips/reorder", status_code=204)
def reorder_clips(
payload: AudioClipReorder,
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> None:
if not session.is_admin and session.external_team_id != payload.external_team_id:
raise HTTPException(status_code=403, detail="You can only reorder clips for your selected team")
clips = db.scalars(
select(AudioClip)
.join(AudioClip.asset)
.where(
AudioAsset.external_team_id == payload.external_team_id,
AudioAsset.owner_external_player_id == payload.owner_external_player_id,
)
).all()
clips_by_id = {clip.id: clip for clip in clips}
if len(clips_by_id) != len(clips) or set(clips_by_id) != set(payload.clip_ids):
raise HTTPException(status_code=422, detail="Clip order must include every clip for that player")
for sort_order, clip_id in enumerate(payload.clip_ids):
clips_by_id[clip_id].sort_order = sort_order
db.commit()
@router.get("/files/{relative_path:path}")
def media_file(relative_path: str) -> FileResponse:
path = storage.absolute_path(relative_path)

View File

@@ -67,6 +67,7 @@ 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)
sort_order: int | None = None
class AudioClipResponse(BaseModel):
@@ -78,6 +79,7 @@ class AudioClipResponse(BaseModel):
label: str
start_ms: int
end_ms: int
sort_order: int
normalization_status: str
normalized_url: str | None
waveform_duration_ms: int | None = None
@@ -116,6 +118,12 @@ class GamePrepResponse(BaseModel):
assignments: list[GameAssignmentResponse]
class AudioClipReorder(BaseModel):
external_team_id: str
owner_external_player_id: str
clip_ids: list[int] = Field(min_length=1)
class PlaybackSessionCreate(BaseModel):
external_team_id: str

View File

@@ -135,7 +135,7 @@ def test_walkup_session_selection_is_persisted_in_session() -> None:
assert response.json()["external_player_id"] == "player-1002"
def test_player_can_attach_multiple_clips_to_same_game() -> None:
def test_player_can_pin_a_clip_to_multiple_games_independently() -> None:
login = client.post("/auth/admin/login", json={"username": "admin", "password": "admin"})
assert login.status_code == 200
@@ -159,18 +159,9 @@ def test_player_can_attach_multiple_clips_to_same_game() -> None:
normalization_status="ready",
normalized_path="clips/intro.mp3",
)
second_clip = AudioClip(
asset_id=asset.id,
label="Chorus",
start_ms=12000,
end_ms=22000,
normalization_status="ready",
normalized_path="clips/chorus.mp3",
)
db.add_all([first_clip, second_clip])
db.add(first_clip)
db.commit()
db.refresh(first_clip)
db.refresh(second_clip)
db.close()
first_response = client.post(
@@ -183,28 +174,68 @@ def test_player_can_attach_multiple_clips_to_same_game() -> None:
"status": "ready",
},
)
second_response = client.post(
"/games/game-1/assignments",
second_game_response = client.post(
"/games/game-2/assignments",
json={
"external_team_id": "team-1",
"external_player_id": "player-1",
"clip_id": second_clip.id,
"clip_id": first_clip.id,
"batting_slot": 1,
"status": "ready",
},
)
assert first_response.status_code == 200
assert second_response.status_code == 200
assert second_game_response.status_code == 200
assert first_response.json()["start_ms"] == 0
assert first_response.json()["end_ms"] == 10000
assert second_response.json()["start_ms"] == 12000
assert second_response.json()["end_ms"] == 22000
assignments = client.get("/games/game-1/assignments")
assert assignments.status_code == 200
assignment_ids = [item["clip_id"] for item in assignments.json()]
assert assignment_ids == [second_clip.id, first_clip.id]
game_one_assignments = client.get("/games/game-1/assignments")
game_two_assignments = client.get("/games/game-2/assignments")
assert game_one_assignments.status_code == 200
assert game_two_assignments.status_code == 200
assert [item["clip_id"] for item in game_one_assignments.json()] == [first_clip.id]
assert [item["clip_id"] for item in game_two_assignments.json()] == [first_clip.id]
db = SessionLocal()
session = db.query(UserSession).filter_by(session_token="admin-session").one_or_none()
if session is None:
session = UserSession(
session_token="admin-session",
provider="teamsnap",
external_team_id="team-1",
external_player_id="player-1",
)
db.add(session)
db.commit()
else:
session.external_team_id = "team-1"
session.external_player_id = "player-1"
db.commit()
db.close()
client.cookies.set(settings.session_cookie_name, "admin-session")
pins_before_delete = client.get("/games/pins", params={"external_player_id": "player-1"})
assert pins_before_delete.status_code == 200
assert [item["clip_id"] for item in pins_before_delete.json()] == [first_clip.id, first_clip.id]
delete_response = client.delete(
f"/games/game-1/assignments/{first_response.json()['id']}",
params={"external_player_id": "player-1"},
)
assert delete_response.status_code == 204
game_one_after_delete = client.get("/games/game-1/assignments")
game_two_after_delete = client.get("/games/game-2/assignments")
assert game_one_after_delete.status_code == 200
assert game_two_after_delete.status_code == 200
assert game_one_after_delete.json() == []
assert [item["clip_id"] for item in game_two_after_delete.json()] == [first_clip.id]
client.cookies.set(settings.session_cookie_name, "admin-session")
pins = client.get("/games/pins", params={"external_player_id": "player-1"})
assert pins.status_code == 200
assert [item["clip_id"] for item in pins.json()] == [first_clip.id]
def test_upload_creates_default_clip_and_clip_ranges_can_be_updated() -> None:
@@ -248,6 +279,73 @@ def test_upload_creates_default_clip_and_clip_ranges_can_be_updated() -> None:
assert updated_clip["label"] == "Fresh track"
def test_player_can_reorder_clips_in_their_library() -> None:
db = SessionLocal()
session = UserSession(
session_token="player-session",
provider="teamsnap",
external_team_id="team-9",
external_player_id="player-9",
)
db.add(session)
db.commit()
db.close()
client.cookies.set(settings.session_cookie_name, "player-session")
asset = AudioAsset(
external_team_id="team-9",
owner_external_player_id="player-9",
title="Song",
original_filename="song.mp3",
mime_type="audio/mpeg",
size_bytes=123,
storage_path="uploads/song.mp3",
)
db = SessionLocal()
db.add(asset)
db.flush()
first_clip = AudioClip(
asset_id=asset.id,
label="Intro",
start_ms=0,
end_ms=10000,
sort_order=0,
normalization_status="ready",
normalized_path="clips/intro.mp3",
)
second_clip = AudioClip(
asset_id=asset.id,
label="Chorus",
start_ms=12000,
end_ms=22000,
sort_order=1,
normalization_status="ready",
normalized_path="clips/chorus.mp3",
)
db.add_all([first_clip, second_clip])
db.commit()
db.refresh(first_clip)
db.refresh(second_clip)
db.close()
reorder = client.post(
"/media/clips/reorder",
json={
"external_team_id": "team-9",
"owner_external_player_id": "player-9",
"clip_ids": [second_clip.id, first_clip.id],
},
)
assert reorder.status_code == 204
clips = client.get("/media/clips", params={"external_team_id": "team-9", "owner_external_player_id": "player-9"})
assert clips.status_code == 200
assert [item["id"] for item in clips.json()] == [second_clip.id, first_clip.id]
assert [item["sort_order"] for item in clips.json()] == [0, 1]
def test_clip_updates_can_use_player_scoped_authorization() -> None:
uploader_session = UserSession(session_token="uploader-session", provider="teamsnap")
editor_session = UserSession(session_token="editor-session", provider="teamsnap")