-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add ball raw data pulls #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,215 @@ | ||
| """ | ||
| Fetch ball data recordings for a session and download raw JSON for each recording | ||
| that has a URL. Uses GraphQL for session/recording metadata and HTTP GET for the | ||
| raw data files. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import json | ||
| import os | ||
| import sys | ||
| from datetime import datetime, timezone | ||
|
|
||
| import httpx | ||
|
|
||
| from playerdatapy.constants import API_BASE_URL | ||
| from playerdatapy.gqlauth import AuthenticationType, GraphqlAuth | ||
| from playerdatapy.gqlclient import Client | ||
|
|
||
| # ----------------------------------------------------------------------------- | ||
| # Config (env or override below) | ||
| # ----------------------------------------------------------------------------- | ||
| CLIENT_ID = os.environ.get("CLIENT_ID") | ||
| CLUB_ID = os.environ.get("CLUB_ID") | ||
| # ----------------------------------------------------------------------------- | ||
| # GraphQL | ||
| # ----------------------------------------------------------------------------- | ||
| SESSIONS_QUERY = """ | ||
| query($clubIdEq: ID!, $startTimeGteq: ISO8601DateTime!, $endTimeLteq: ISO8601DateTime!) { | ||
| sessions(filter: { clubIdEq: $clubIdEq, startTimeGteq: $startTimeGteq, endTimeLteq: $endTimeLteq }) { | ||
| id | ||
| startTime | ||
| endTime | ||
| } | ||
| } | ||
| """ | ||
|
|
||
| SESSION_BALL_DATA_QUERY = """ | ||
| query($sessionId: ID!) { | ||
| session(id: $sessionId) { | ||
| id | ||
| startTime | ||
| endTime | ||
| ballDataRecordings(withData: true) { | ||
| id | ||
| url(format: json) | ||
| ball { id serialNumber } | ||
| } | ||
| } | ||
| } | ||
| """ | ||
|
|
||
|
|
||
| def _record_count(data: list | dict) -> int: | ||
| if isinstance(data, list): | ||
| return len(data) | ||
| return len(data.get("records", [])) | ||
|
|
||
|
|
||
| def _format_session_line(i: int, s: dict) -> str: | ||
| """One line for a session: number, start–end, id.""" | ||
| start = s.get("startTime", "")[:19].replace("T", " ") | ||
| end = s.get("endTime", "")[:19].replace("T", " ") | ||
| sid = s.get("id", "") | ||
| return f" {i}. {start} – {end} {sid}" | ||
|
|
||
|
|
||
| def _choose_session(sessions: list[dict]) -> dict | None: | ||
| """ | ||
| Let the user choose a session when running interactively; otherwise use latest. | ||
| Returns the chosen session dict or None if invalid/abort. | ||
| """ | ||
| if not sessions: | ||
| return None | ||
|
|
||
| print("Sessions (most recent first):") | ||
| for i, s in enumerate(sessions, start=1): | ||
| print(_format_session_line(i, s)) | ||
|
|
||
| if not sys.stdin.isatty(): | ||
| chosen = sessions[0] | ||
| print(f"Using latest session: {chosen['id']}") | ||
| return chosen | ||
|
|
||
| n = len(sessions) | ||
| try: | ||
| raw = input(f"Select session (1–{n}, or Enter for latest): ").strip() | ||
| if not raw: | ||
| return sessions[0] | ||
| idx = int(raw) | ||
| if 1 <= idx <= n: | ||
| return sessions[idx - 1] | ||
| except (ValueError, EOFError): | ||
| pass | ||
| print("Invalid choice; using latest session.") | ||
| return sessions[0] | ||
|
|
||
|
|
||
| async def fetch_recordings_for_session( | ||
| client: Client, | ||
| session_id: str, | ||
| ) -> list[dict] | None: | ||
| """Return session dict with ballDataRecordings, or None if not found.""" | ||
| resp = await client.execute( | ||
| query=SESSION_BALL_DATA_QUERY, | ||
| variables={"sessionId": session_id}, | ||
| ) | ||
| data = client.get_data(resp) | ||
| return data.get("session") | ||
|
|
||
|
|
||
| async def download_recording( | ||
| http_client: httpx.AsyncClient, | ||
| recording: dict, | ||
| out_dir: str, | ||
| ) -> bool: | ||
| """Download one recording's raw JSON to out_dir. Returns True if saved, False if skipped.""" | ||
| url = recording.get("url") | ||
| if not url: | ||
| ball = recording.get("ball") or {} | ||
| serial = ball.get("serialNumber", "?") | ||
| print(f" Skip {recording['id']} (Ball {serial}): no URL") | ||
| return False | ||
| if url.startswith("/"): | ||
| url = f"{API_BASE_URL.rstrip('/')}{url}" | ||
|
|
||
| ball = recording.get("ball") or {} | ||
| serial = ball.get("serialNumber", "?") | ||
|
|
||
| try: | ||
| r = await http_client.get(url) | ||
| r.raise_for_status() | ||
| raw = r.json() | ||
| except httpx.HTTPStatusError as e: | ||
| print(f" Skip {recording['id']} (Ball {serial}): {e.response.status_code}") | ||
| return False | ||
| except httpx.RequestError as e: | ||
| reason = str(e).strip() or type(e).__name__ | ||
| print(f" Skip {recording['id']} (Ball {serial}): {reason}") | ||
| return False | ||
|
|
||
| if _record_count(raw) == 0: | ||
| print(f" Skip {recording['id']} (Ball {serial}): empty data") | ||
| return False | ||
|
|
||
| path = os.path.join(out_dir, f"{recording['id']}.json") | ||
| with open(path, "w") as f: | ||
| json.dump(raw, f, indent=2) | ||
| print(f" Ball {serial}: {_record_count(raw)} records -> {path}") | ||
| return True | ||
|
|
||
|
|
||
| async def main() -> None: | ||
| auth = GraphqlAuth( | ||
| client_id=CLIENT_ID, | ||
| type=AuthenticationType.AUTHORISATION_CODE_FLOW_PCKE, | ||
| ) | ||
| client = Client( | ||
| url=f"{API_BASE_URL}/api/graphql", | ||
| headers={"Authorization": f"Bearer {auth._get_authentication_token()}"}, | ||
| ) | ||
|
|
||
| now = datetime.now(timezone.utc) | ||
| # No time filter: get all sessions (use a wide range for the required query params) | ||
| list_vars = { | ||
| "clubIdEq": CLUB_ID, | ||
| "startTimeGteq": datetime(2000, 1, 1, tzinfo=timezone.utc).isoformat(), | ||
| "endTimeLteq": now.isoformat(), | ||
| } | ||
|
|
||
| resp = await client.execute(query=SESSIONS_QUERY, variables=list_vars) | ||
| sessions = client.get_data(resp).get("sessions") or [] | ||
|
|
||
| if not sessions: | ||
| print("No sessions found.") | ||
| return | ||
| print(f"Found {len(sessions)} session(s).") | ||
|
|
||
| chosen = _choose_session(sessions) | ||
| if not chosen: | ||
| return | ||
|
|
||
| session = await fetch_recordings_for_session(client, chosen["id"]) | ||
| if not session: | ||
| print(f"Session {chosen['id']} not found.") | ||
| return | ||
|
|
||
| recordings_with_url = [ | ||
| r for r in (session.get("ballDataRecordings") or []) if r.get("url") | ||
| ] | ||
| if not recordings_with_url: | ||
| print(f"No ball data recordings with URLs for session {session['id']}.") | ||
| return | ||
|
|
||
| out_dir = session["id"] | ||
| os.makedirs(out_dir, exist_ok=True) | ||
| print(f"Session {session['id']} ({session['startTime']} – {session['endTime']})") | ||
| print(f"Downloading {len(recordings_with_url)} recording(s) to {out_dir}/") | ||
|
|
||
| headers = {"Authorization": f"Bearer {auth._get_authentication_token()}"} | ||
| async with httpx.AsyncClient(headers=headers, timeout=60.0) as http_client: | ||
| ok = sum( | ||
| await asyncio.gather( | ||
| *[ | ||
| download_recording(http_client, r, out_dir) | ||
| for r in recordings_with_url | ||
| ] | ||
| ) | ||
| ) | ||
| print(f"Done: {ok}/{len(recordings_with_url)} saved.") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| asyncio.run(main()) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| """ | ||
| Fetch ball data recordings for a session and download raw JSON for each recording | ||
| that has a URL. Uses the Pydantic API (PlayerDataAPI + query builders) for | ||
| session/recording metadata and HTTP GET for the raw data files. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import json | ||
| import os | ||
| import sys | ||
| import httpx | ||
|
|
||
| from playerdatapy.constants import API_BASE_URL | ||
| from playerdatapy.gqlauth import AuthenticationType | ||
| from playerdatapy.playerdata_api import PlayerDataAPI | ||
|
|
||
| from examples.pydantic.queries.club_sessions import club_sessions | ||
| from examples.pydantic.queries.session_ball_data import session_ball_data | ||
|
|
||
| # ----------------------------------------------------------------------------- | ||
| # Config (env or override below) | ||
| # ----------------------------------------------------------------------------- | ||
| CLIENT_ID = os.environ.get("CLIENT_ID") | ||
| CLUB_ID = os.environ.get("CLUB_ID") | ||
|
|
||
|
|
||
| def _record_count(data: list | dict) -> int: | ||
| if isinstance(data, list): | ||
| return len(data) | ||
| return len(data.get("records", [])) | ||
|
|
||
|
|
||
| def _format_session_line(i: int, s: dict) -> str: | ||
| """One line for a session: number, start–end, id.""" | ||
| start = (s.get("startTime") or "")[:19].replace("T", " ") | ||
| end = (s.get("endTime") or "")[:19].replace("T", " ") | ||
| sid = s.get("id", "") | ||
| return f" {i}. {start} – {end} {sid}" | ||
|
|
||
|
|
||
| def _choose_session(sessions: list[dict]) -> dict | None: | ||
| """ | ||
| Let the user choose a session when running interactively; otherwise use latest. | ||
| Returns the chosen session dict or None if invalid/abort. | ||
| """ | ||
| if not sessions: | ||
| return None | ||
|
|
||
| print("Sessions (most recent first):") | ||
| for i, s in enumerate(sessions, start=1): | ||
| print(_format_session_line(i, s)) | ||
|
|
||
| if not sys.stdin.isatty(): | ||
| chosen = sessions[0] | ||
| print(f"Using latest session: {chosen['id']}") | ||
| return chosen | ||
|
|
||
| n = len(sessions) | ||
| try: | ||
| raw = input(f"Select session (1–{n}, or Enter for latest): ").strip() | ||
| if not raw: | ||
| return sessions[0] | ||
| idx = int(raw) | ||
| if 1 <= idx <= n: | ||
| return sessions[idx - 1] | ||
| except (ValueError, EOFError): | ||
| pass | ||
| print("Invalid choice; using latest session.") | ||
| return sessions[0] | ||
|
|
||
|
|
||
| async def download_recording( | ||
| http_client: httpx.AsyncClient, | ||
| recording: dict, | ||
| out_dir: str, | ||
| ) -> bool: | ||
| """Download one recording's raw JSON to out_dir. Returns True if saved, False if skipped.""" | ||
| url = recording.get("url") | ||
| if not url: | ||
| ball = recording.get("ball") or {} | ||
| serial = ball.get("serialNumber", "?") | ||
| print(f" Skip {recording['id']} (Ball {serial}): no URL") | ||
| return False | ||
| if url.startswith("/"): | ||
| url = f"{API_BASE_URL.rstrip('/')}{url}" | ||
|
|
||
| ball = recording.get("ball") or {} | ||
| serial = ball.get("serialNumber", "?") | ||
|
|
||
| try: | ||
| r = await http_client.get(url) | ||
| r.raise_for_status() | ||
| raw = r.json() | ||
| except httpx.HTTPStatusError as e: | ||
| print(f" Skip {recording['id']} (Ball {serial}): {e.response.status_code}") | ||
| return False | ||
| except httpx.RequestError as e: | ||
| reason = str(e).strip() or type(e).__name__ | ||
| print(f" Skip {recording['id']} (Ball {serial}): {reason}") | ||
| return False | ||
|
|
||
| if _record_count(raw) == 0: | ||
| print(f" Skip {recording['id']} (Ball {serial}): empty data") | ||
| return False | ||
|
|
||
| path = os.path.join(out_dir, f"{recording['id']}.json") | ||
| with open(path, "w") as f: | ||
| json.dump(raw, f, indent=2) | ||
| print(f" Ball {serial}: {_record_count(raw)} records -> {path}") | ||
| return True | ||
|
|
||
|
|
||
| async def main() -> None: | ||
| api = PlayerDataAPI( | ||
| client_id=CLIENT_ID, | ||
| client_secret="", | ||
| authentication_type=AuthenticationType.AUTHORISATION_CODE_FLOW_PCKE, | ||
| ) | ||
|
|
||
| sessions_response = await api.run_queries( | ||
| "ClubSessionsQuery", | ||
| club_sessions(club_id=CLUB_ID), | ||
| ) | ||
| sessions = sessions_response.get("sessions") or [] | ||
|
|
||
| if not sessions: | ||
| print("No sessions found.") | ||
| return | ||
| print(f"Found {len(sessions)} session(s).") | ||
|
|
||
| chosen = _choose_session(sessions) | ||
| if not chosen: | ||
| return | ||
|
|
||
| session_response = await api.run_queries( | ||
| "SessionBallDataQuery", | ||
| session_ball_data(chosen["id"]), | ||
| ) | ||
| session = session_response.get("session") | ||
|
|
||
| if not session: | ||
| print(f"Session {chosen['id']} not found.") | ||
| return | ||
|
|
||
| recordings_with_url = [ | ||
| r for r in (session.get("ballDataRecordings") or []) if r.get("url") | ||
| ] | ||
| if not recordings_with_url: | ||
| print(f"No ball data recordings with URLs for session {session['id']}.") | ||
| return | ||
|
|
||
| out_dir = session["id"] | ||
| os.makedirs(out_dir, exist_ok=True) | ||
| print(f"Session {session['id']} ({session['startTime']} – {session['endTime']})") | ||
| print(f"Downloading {len(recordings_with_url)} recording(s) to {out_dir}/") | ||
|
|
||
| headers = {"Authorization": f"Bearer {api._get_authentication_token()}"} | ||
| async with httpx.AsyncClient(headers=headers, timeout=60.0) as http_client: | ||
| ok = sum( | ||
| await asyncio.gather( | ||
| *[ | ||
| download_recording(http_client, r, out_dir) | ||
| for r in recordings_with_url | ||
| ] | ||
| ) | ||
| ) | ||
| print(f"Done: {ok}/{len(recordings_with_url)} saved.") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| asyncio.run(main()) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| from playerdatapy.custom_queries import Query | ||
| from playerdatapy.input_types import SessionsSessionFilter | ||
| from playerdatapy.custom_fields import SessionInterface | ||
|
|
||
|
|
||
| def club_sessions(club_id: str) -> SessionInterface: | ||
| return Query.sessions(filter=SessionsSessionFilter(clubIdEq=club_id)).fields( | ||
| SessionInterface.id, SessionInterface.start_time, SessionInterface.end_time | ||
| ) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.