diff --git a/backend/app/models.py b/backend/app/models.py index 468c5b8..b634d2e 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -56,6 +56,7 @@ class AudioClip(Base): 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) + hidden: Mapped[bool] = mapped_column(Boolean, default=False, 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) diff --git a/backend/app/routes/games.py b/backend/app/routes/games.py index 24b781c..f4ad6ec 100644 --- a/backend/app/routes/games.py +++ b/backend/app/routes/games.py @@ -65,7 +65,10 @@ def list_assignments( _: UserSession = Depends(require_session), db: Session = Depends(get_db), ) -> list[GameAssignmentResponse]: - query = select(GameAssignment).join(GameAssignment.clip).where(GameAssignment.external_game_id == external_game_id) + query = select(GameAssignment).join(GameAssignment.clip).where( + GameAssignment.external_game_id == external_game_id, + AudioClip.hidden.is_(False), + ) if external_player_id: query = query.where(GameAssignment.external_player_id == external_player_id) assignments = db.scalars(query.order_by(AudioClip.sort_order.asc(), GameAssignment.updated_at.desc())).all() @@ -82,6 +85,8 @@ def create_assignment( 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.hidden: + raise HTTPException(status_code=404, detail="Clip not found") 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: @@ -142,7 +147,7 @@ def prepare_game( assignments = db.scalars( select(GameAssignment) .join(GameAssignment.clip) - .where(GameAssignment.external_game_id == external_game_id) + .where(GameAssignment.external_game_id == external_game_id, AudioClip.hidden.is_(False)) .order_by(AudioClip.sort_order.asc(), GameAssignment.updated_at.desc()) ).all() external_team_id = assignments[0].external_team_id if assignments else "" @@ -192,11 +197,15 @@ def trigger_playback( 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="Pin not found") + if assignment.clip.hidden: + raise HTTPException(status_code=404, detail="Pin 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 clip.hidden: + 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 diff --git a/backend/app/routes/media.py b/backend/app/routes/media.py index fd47f0c..5b7f913 100644 --- a/backend/app/routes/media.py +++ b/backend/app/routes/media.py @@ -41,6 +41,7 @@ def clip_to_response(clip: AudioClip) -> AudioClipResponse: start_ms=clip.start_ms, end_ms=clip.end_ms, sort_order=clip.sort_order, + hidden=clip.hidden, normalization_status=clip.normalization_status, normalized_url=normalized_url, waveform_duration_ms=waveform["duration_ms"] if waveform else None, @@ -340,6 +341,8 @@ def update_clip( clip.end_ms = payload.end_ms if payload.sort_order is not None: clip.sort_order = payload.sort_order + if payload.hidden is not None: + clip.hidden = payload.hidden db.commit() db.refresh(clip) return clip_to_response(clip) @@ -376,6 +379,7 @@ def delete_clip( def list_clips( external_team_id: str, owner_external_player_id: str | None = None, + include_hidden: bool = False, _: UserSession = Depends(require_session), db: Session = Depends(get_db), ) -> list[AudioClipResponse]: @@ -387,6 +391,8 @@ def list_clips( ) if owner_external_player_id: query = query.where(AudioAsset.owner_external_player_id == owner_external_player_id) + if not include_hidden: + query = query.where(AudioClip.hidden.is_(False)) clips = db.scalars(query).all() return [clip_to_response(clip) for clip in clips] diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 96d02cb..94f90f1 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -68,6 +68,7 @@ class AudioClipUpdate(BaseModel): start_ms: int = Field(ge=0) end_ms: int = Field(gt=0) sort_order: int | None = None + hidden: bool | None = None class AudioClipResponse(BaseModel): @@ -80,6 +81,7 @@ class AudioClipResponse(BaseModel): start_ms: int end_ms: int sort_order: int + hidden: bool normalization_status: str normalized_url: str | None waveform_duration_ms: int | None = None diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 19ee69e..0a833b6 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -346,6 +346,104 @@ def test_player_can_reorder_clips_in_their_library() -> None: assert [item["sort_order"] for item in clips.json()] == [0, 1] +def test_hidden_clips_are_removed_from_gameday_views_but_remain_pinnable() -> None: + login = client.post("/auth/admin/login", json={"username": "admin", "password": "admin"}) + assert login.status_code == 200 + + db = SessionLocal() + session = UserSession( + session_token="player-hidden-session", + provider="teamsnap", + external_team_id="team-hidden", + external_player_id="player-hidden", + ) + db.add(session) + db.flush() + asset = AudioAsset( + external_team_id="team-hidden", + owner_external_player_id="player-hidden", + uploaded_by_session_id=session.id, + title="Hidden song", + original_filename="hidden.mp3", + mime_type="audio/mpeg", + size_bytes=123, + storage_path="uploads/hidden.mp3", + ) + db.add(asset) + db.flush() + clip = AudioClip( + asset_id=asset.id, + label="Hidden clip", + start_ms=0, + end_ms=10000, + normalization_status="ready", + normalized_path="clips/hidden.mp3", + ) + db.add(clip) + db.commit() + db.refresh(clip) + db.close() + + client.cookies.set(settings.session_cookie_name, "player-hidden-session") + + pin_response = client.post( + "/games/game-hidden/assignments", + json={ + "external_team_id": "team-hidden", + "external_player_id": "player-hidden", + "clip_id": clip.id, + "batting_slot": 1, + "status": "ready", + }, + ) + assert pin_response.status_code == 200 + + visible_before_hide = client.get( + "/media/clips", + params={"external_team_id": "team-hidden", "owner_external_player_id": "player-hidden"}, + ) + assert visible_before_hide.status_code == 200 + assert [item["id"] for item in visible_before_hide.json()] == [clip.id] + assert visible_before_hide.json()[0]["hidden"] is False + + hide_response = client.patch( + f"/media/clips/{clip.id}", + params={"owner_external_player_id": "player-hidden"}, + json={"start_ms": 0, "end_ms": 10000, "hidden": True}, + ) + assert hide_response.status_code == 200 + assert hide_response.json()["hidden"] is True + + visible_after_hide = client.get( + "/media/clips", + params={"external_team_id": "team-hidden", "owner_external_player_id": "player-hidden"}, + ) + assert visible_after_hide.status_code == 200 + assert visible_after_hide.json() == [] + + hidden_in_library = client.get( + "/media/clips", + params={ + "external_team_id": "team-hidden", + "owner_external_player_id": "player-hidden", + "include_hidden": True, + }, + ) + assert hidden_in_library.status_code == 200 + assert [item["id"] for item in hidden_in_library.json()] == [clip.id] + assert hidden_in_library.json()[0]["hidden"] is True + + game_assignments = client.get("/games/game-hidden/assignments") + prep = client.get("/games/game-hidden/prep") + pins = client.get("/games/pins", params={"external_player_id": "player-hidden"}) + assert game_assignments.status_code == 200 + assert prep.status_code == 200 + assert pins.status_code == 200 + assert game_assignments.json() == [] + assert prep.json()["assignments"] == [] + assert [item["clip_id"] for item in pins.json()] == [clip.id] + + 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") diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index bc13e1e..96991a9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -131,11 +131,11 @@ export const api = { throw new Error(await response.text()); } }, - listClips: (teamId: string, playerId?: string) => + listClips: (teamId: string, playerId?: string, includeHidden = false) => request( `/media/clips?external_team_id=${encodeURIComponent(teamId)}${ playerId ? `&owner_external_player_id=${encodeURIComponent(playerId)}` : "" - }`, + }${includeHidden ? "&include_hidden=true" : ""}`, ), createClip: (payload: { asset_id: number; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 4142075..74d2482 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -47,6 +47,7 @@ export interface AudioClip { start_ms: number; end_ms: number; sort_order: number; + hidden: boolean; normalization_status: string; normalized_url?: string | null; waveform_duration_ms?: number | null; @@ -59,6 +60,7 @@ export interface AudioClipUpdate { end_ms: number; label?: string; sort_order?: number | null; + hidden?: boolean | null; } export interface GameAssignment { diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index 5be990b..2dd9e20 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -30,7 +30,7 @@ export function GamePage() { }, [searchParams, selectedGameId, walkup.nextGame]); const clipsQuery = useQuery({ - queryKey: ["clips", teamId, playerId], + queryKey: ["clips", teamId, playerId, "visible"], queryFn: () => api.listClips(teamId, playerId), enabled: Boolean(teamId && playerId), }); diff --git a/frontend/src/pages/OperatorPage.tsx b/frontend/src/pages/OperatorPage.tsx index 3a32ec7..541b11d 100644 --- a/frontend/src/pages/OperatorPage.tsx +++ b/frontend/src/pages/OperatorPage.tsx @@ -653,7 +653,7 @@ function LibraryClips({ pinnedAssignmentsByClipId: Map; }) { const fallbackClipsQuery = useQuery({ - queryKey: ["clips", teamId, playerId], + queryKey: ["clips", teamId, playerId, "visible"], queryFn: () => api.listClips(teamId, playerId), enabled: Boolean(teamId && playerId), });