import logging from itertools import islice from django.conf import settings from apps.providers.clients import BalldontlieClient from apps.providers.contracts import ( CompetitionPayload, NormalizedSyncPayload, PlayerCareerPayload, PlayerPayload, PlayerStatsPayload, SeasonPayload, TeamPayload, ) from apps.providers.interfaces import BaseProviderAdapter from apps.providers.exceptions import ProviderUnauthorizedError from apps.providers.services.balldontlie_mappings import ( map_competitions, map_player_stats, map_players, map_seasons, map_teams, ) logger = logging.getLogger(__name__) class BalldontlieProviderAdapter(BaseProviderAdapter): """HTTP MVP adapter for balldontlie (NBA-centric data source).""" namespace = "balldontlie" def __init__(self, client: BalldontlieClient | None = None): self.client = client or BalldontlieClient() @property def configured_seasons(self) -> list[int]: return settings.PROVIDER_BALLDONTLIE_SEASONS @staticmethod def _chunked(values: list[int], size: int): iterator = iter(values) while True: chunk = list(islice(iterator, size)) if not chunk: return yield chunk def _fetch_game_ids(self) -> list[int]: game_ids: set[int] = set() for season in self.configured_seasons: rows = self.client.list_paginated( "games", params={"seasons[]": season}, per_page=settings.PROVIDER_BALLDONTLIE_GAMES_PER_PAGE, page_limit=settings.PROVIDER_BALLDONTLIE_GAMES_PAGE_LIMIT, ) for row in rows: game_id = row.get("id") if isinstance(game_id, int): game_ids.add(game_id) return sorted(game_ids) def _fetch_stats_rows(self) -> list[dict]: game_ids = self._fetch_game_ids() if not game_ids: logger.info( "provider_stats_skipped_no_games", extra={"provider": self.namespace, "seasons": self.configured_seasons}, ) return [] all_rows: list[dict] = [] try: # Use game_ids[] query as documented in balldontlie getting-started flow. for game_id_chunk in self._chunked(game_ids, 25): rows = self.client.list_paginated( "stats", params={"game_ids[]": game_id_chunk}, per_page=settings.PROVIDER_BALLDONTLIE_STATS_PER_PAGE, page_limit=settings.PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT, ) all_rows.extend(rows) except ProviderUnauthorizedError as exc: if settings.PROVIDER_BALLDONTLIE_STATS_STRICT: raise logger.warning( "provider_stats_unauthorized_degraded", extra={ "provider": self.namespace, "path": exc.path, "status_code": exc.status_code, "detail": exc.detail, }, ) return [] return all_rows def search_players(self, *, query: str = "", limit: int = 50, offset: int = 0) -> list[PlayerPayload]: params = {"search": query} if query else None rows = self.client.list_paginated( "players", params=params, per_page=min(limit, settings.PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE), page_limit=1, ) mapped = map_players(rows) return mapped[offset : offset + limit] def fetch_player(self, *, external_player_id: str) -> PlayerPayload | None: if not external_player_id.startswith("player-"): return None player_id = external_player_id.replace("player-", "", 1) payload = self.client.get_json(f"players/{player_id}") data = payload.get("data") if not isinstance(data, dict): return None mapped = map_players([data]) return mapped[0] if mapped else None def fetch_players(self) -> list[PlayerPayload]: rows = self.client.list_paginated( "players", per_page=settings.PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE, page_limit=settings.PROVIDER_BALLDONTLIE_PLAYERS_PAGE_LIMIT, ) return map_players(rows) def fetch_competitions(self) -> list[CompetitionPayload]: return map_competitions() def fetch_teams(self) -> list[TeamPayload]: payload = self.client.get_json("teams") rows = payload.get("data") or [] return map_teams(rows if isinstance(rows, list) else []) def fetch_seasons(self) -> list[SeasonPayload]: return map_seasons(self.configured_seasons) def fetch_player_stats(self) -> list[PlayerStatsPayload]: all_rows = self._fetch_stats_rows() player_stats, _ = map_player_stats(all_rows, allowed_seasons=self.configured_seasons) return player_stats def fetch_player_careers(self) -> list[PlayerCareerPayload]: all_rows = self._fetch_stats_rows() _, player_careers = map_player_stats(all_rows, allowed_seasons=self.configured_seasons) return player_careers def sync_all(self) -> NormalizedSyncPayload: logger.info( "provider_sync_start", extra={"provider": self.namespace, "seasons": self.configured_seasons}, ) competitions = self.fetch_competitions() teams = self.fetch_teams() seasons = self.fetch_seasons() players = self.fetch_players() all_rows = self._fetch_stats_rows() player_stats, player_careers = map_player_stats(all_rows, allowed_seasons=self.configured_seasons) logger.info( "provider_sync_complete", extra={ "provider": self.namespace, "competitions": len(competitions), "teams": len(teams), "seasons": len(seasons), "players": len(players), "player_stats": len(player_stats), "player_careers": len(player_careers), }, ) return { "players": players, "competitions": competitions, "teams": teams, "seasons": seasons, "player_stats": player_stats, "player_careers": player_careers, "cursor": None, } def sync_incremental(self, *, cursor: str | None = None) -> NormalizedSyncPayload: payload = self.sync_all() payload["cursor"] = cursor return payload