From 745aebab3bfcdbd31cf12d1ac4ee3450e05f445b Mon Sep 17 00:00:00 2001 From: ChuckBuilds <33324927+ChuckBuilds@users.noreply.github.com> Date: Sat, 3 May 2025 21:07:01 -0500 Subject: [PATCH] feat(soccer): Optimize API calls using team-league map --- src/soccer_managers.py | 189 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 175 insertions(+), 14 deletions(-) diff --git a/src/soccer_managers.py b/src/soccer_managers.py index 18c32080..394b48b0 100644 --- a/src/soccer_managers.py +++ b/src/soccer_managers.py @@ -97,6 +97,8 @@ class BaseSoccerManager: # Class attribute to store soccer_config for shared access _soccer_config_shared = {} + _team_league_map = {} # In-memory cache for the map + _map_last_updated = 0 def __init__(self, config: Dict[str, Any], display_manager: DisplayManager): self.display_manager = display_manager @@ -112,9 +114,11 @@ class BaseSoccerManager: self.current_game = None self.fonts = self._load_fonts() self.favorite_teams = self.soccer_config.get("favorite_teams", []) - self.target_leagues = self.soccer_config.get("leagues", list(LEAGUE_SLUGS.keys())) # Get target leagues from config + self.target_leagues_config = self.soccer_config.get("leagues", list(LEAGUE_SLUGS.keys())) # Get target leagues from config self.recent_hours = self.soccer_config.get("recent_game_hours", 168) # Used for recent past AND upcoming future display window self.upcoming_fetch_days = self.soccer_config.get("upcoming_fetch_days", 7) # Days ahead to fetch (default: tomorrow) + self.team_map_file = self.soccer_config.get("team_map_file", "assets/data/team_league_map.json") + self.team_map_update_days = self.soccer_config.get("team_map_update_days", 7) # How often to update the map self.logger.setLevel(logging.DEBUG) @@ -126,11 +130,110 @@ class BaseSoccerManager: self.display_height = hardware_config.get("rows", 32) self._logo_cache = {} + + # Ensure data directory exists + os.makedirs(os.path.dirname(self.team_map_file), exist_ok=True) + # Load or build the team map + self._update_team_league_map_if_needed() self.logger.info(f"Initialized Soccer manager with display dimensions: {self.display_width}x{self.display_height}") self.logger.info(f"Logo directory: {self.logo_dir}") - self.logger.info(f"Target leagues: {self.target_leagues}") + self.logger.info(f"Configured target leagues: {self.target_leagues_config}") self.logger.info(f"Upcoming fetch days: {self.upcoming_fetch_days}") # Log new setting + self.logger.info(f"Team map file: {self.team_map_file}") + self.logger.info(f"Team map update interval: {self.team_map_update_days} days") + + # --- Team League Map Management --- + @classmethod + def _load_team_league_map(cls) -> None: + """Load the team-league map from the JSON file.""" + map_file = cls._soccer_config_shared.get("team_map_file", "assets/data/team_league_map.json") + try: + if os.path.exists(map_file): + with open(map_file, 'r') as f: + data = json.load(f) + cls._team_league_map = data.get("map", {}) + cls._map_last_updated = data.get("last_updated", 0) + cls.logger.info(f"[Soccer] Loaded team-league map ({len(cls._team_league_map)} teams) from {map_file}") + else: + cls.logger.info(f"[Soccer] Team-league map file not found: {map_file}. Will attempt to build.") + cls._team_league_map = {} + cls._map_last_updated = 0 + except (IOError, json.JSONDecodeError) as e: + cls.logger.error(f"[Soccer] Error loading team-league map from {map_file}: {e}") + cls._team_league_map = {} + cls._map_last_updated = 0 + + @classmethod + def _save_team_league_map(cls) -> None: + """Save the current team-league map to the JSON file.""" + map_file = cls._soccer_config_shared.get("team_map_file", "assets/data/team_league_map.json") + try: + timestamp = time.time() + with open(map_file, 'w') as f: + json.dump({"last_updated": timestamp, "map": cls._team_league_map}, f, indent=4) + cls._map_last_updated = timestamp + cls.logger.info(f"[Soccer] Saved team-league map ({len(cls._team_league_map)} teams) to {map_file}") + except IOError as e: + cls.logger.error(f"[Soccer] Error saving team-league map to {map_file}: {e}") + + @classmethod + def _build_team_league_map(cls) -> None: + """Fetch data for all known leagues to build the team-to-league map.""" + cls.logger.info("[Soccer] Building team-league map...") + new_map = {} + yesterday = (datetime.now(timezone.utc) - timedelta(days=1)).strftime('%Y%m%d') + + # Fetch data for all leagues defined in LEAGUE_SLUGS to get comprehensive team info + for league_slug in LEAGUE_SLUGS.keys(): + try: + url = ESPN_SOCCER_LEAGUE_SCOREBOARD_URL_FORMAT.format(league_slug) + params = {'dates': yesterday, 'limit': 100} + response = requests.get(url, params=params, timeout=10) # Add timeout + response.raise_for_status() + data = response.json() + cls.logger.debug(f"[Soccer Map Build] Fetched data for {league_slug}") + + for event in data.get("events", []): + event_league_slug = event.get("league", {}).get("slug") + if not event_league_slug: continue # Skip if league slug missing + + competitors = event.get("competitions", [{}])[0].get("competitors", []) + for competitor in competitors: + team_abbr = competitor.get("team", {}).get("abbreviation") + if team_abbr and team_abbr not in new_map: + new_map[team_abbr] = event_league_slug + cls.logger.debug(f"[Soccer Map Build] Mapped {team_abbr} to {event_league_slug}") + + except requests.exceptions.RequestException as e: + # Log errors but continue building map from other leagues + cls.logger.warning(f"[Soccer Map Build] Error fetching data for {league_slug}: {e}") + except Exception as e: + cls.logger.error(f"[Soccer Map Build] Unexpected error processing {league_slug}: {e}", exc_info=True) + + if new_map: + cls._team_league_map = new_map + cls._save_team_league_map() + else: + cls.logger.warning("[Soccer Map Build] Failed to build team-league map. No team data found.") + + @classmethod + def _update_team_league_map_if_needed(cls) -> None: + """Check if the map needs updating and rebuild if necessary.""" + update_interval_seconds = cls._soccer_config_shared.get("team_map_update_days", 7) * 86400 # Convert days to seconds + + # Load map initially if not already loaded + if not cls._team_league_map: + cls._load_team_league_map() + + current_time = time.time() + if not cls._team_league_map or (current_time - cls._map_last_updated > update_interval_seconds): + cls.logger.info("[Soccer] Team-league map is missing or stale. Rebuilding...") + cls._build_team_league_map() + else: + cls.logger.info(f"[Soccer] Team-league map is up-to-date (last updated: {datetime.fromtimestamp(cls._map_last_updated).strftime('%Y-%m-%d %H:%M:%S')}).") + + # --- End Team League Map Management --- @classmethod def _fetch_shared_data(cls, date_str: str = None) -> Optional[Dict]: @@ -138,9 +241,30 @@ class BaseSoccerManager: current_time = time.time() all_data = {"events": []} # Access shared config through the class attribute - target_leagues = cls._soccer_config_shared.get("leagues", list(LEAGUE_SLUGS.keys())) + favorite_teams = cls._soccer_config_shared.get("favorite_teams", []) + target_leagues_config = cls._soccer_config_shared.get("leagues", list(LEAGUE_SLUGS.keys())) upcoming_fetch_days = cls._soccer_config_shared.get("upcoming_fetch_days", 1) # Fetch days + # Determine which leagues to actually fetch + leagues_to_fetch = set() + if favorite_teams and cls._team_league_map: + for team in favorite_teams: + league = cls._team_league_map.get(team) + if league: + leagues_to_fetch.add(league) + else: + cls.logger.warning(f"[Soccer] Favorite team '{team}' not found in team-league map. Cannot filter by its league.") + # If no leagues were found for favorites, should we fetch configured leagues or nothing? + # Current approach: fetch configured leagues as fallback if map lookups fail for all favs. + if not leagues_to_fetch: + cls.logger.warning("[Soccer] No leagues found for any favorite teams in map. Falling back to configured leagues.") + leagues_to_fetch = set(target_leagues_config) + else: + # No favorite teams specified, or map not loaded, use configured leagues + leagues_to_fetch = set(target_leagues_config) + + cls.logger.debug(f"[Soccer] Determined leagues to fetch for shared data: {leagues_to_fetch}") + today = datetime.now(timezone.utc).date() # Generate dates from yesterday up to 'upcoming_fetch_days' in the future dates_to_fetch = [ @@ -154,7 +278,8 @@ class BaseSoccerManager: cls.logger.debug(f"[Soccer] Fetching shared data for dates: {dates_to_fetch}") - for league_slug in target_leagues: + # Fetch data only for the determined leagues + for league_slug in leagues_to_fetch: for fetch_date in dates_to_fetch: cache_key = f"soccer_{league_slug}_{fetch_date}" @@ -189,21 +314,53 @@ class BaseSoccerManager: if response is not None and response.status_code == 404: cls.cache_manager.set(cache_key, {"events": []}) + # Filter events based on favorite teams, if specified + if favorite_teams: + leagues_with_favorites = set() + for event in all_data.get("events", []): + league_slug = event.get("league", {}).get("slug") + competitors = event.get("competitions", [{}])[0].get("competitors", []) + for competitor in competitors: + team_abbr = competitor.get("team", {}).get("abbreviation") + if team_abbr in favorite_teams and league_slug: + leagues_with_favorites.add(league_slug) + break # No need to check other competitor in this event - # Filter combined data by target leagues (might be redundant now but safe) - # This step might not be strictly necessary anymore if fetching is already league-specific - # but keeping it doesn't hurt. - # filtered_events = [ - # event for event in all_data["events"] - # if event.get("league", {}).get("slug") in target_leagues - # ] - # cls._shared_data = {"events": filtered_events} # Use the already combined data + if leagues_with_favorites: + cls.logger.debug(f"[Soccer] Filtering shared data for leagues with favorite teams: {leagues_with_favorites}") + filtered_events = [ + event for event in all_data.get("events", []) + if event.get("league", {}).get("slug") in leagues_with_favorites + ] + all_data["events"] = filtered_events + else: + cls.logger.debug("[Soccer] No favorite teams found in any fetched events. Shared data will be empty.") + all_data["events"] = [] # No relevant leagues found - cls._shared_data = all_data # Store combined data + cls._shared_data = all_data # Store combined (and potentially filtered) data cls._last_shared_update = current_time # Update timestamp return cls._shared_data + def _get_live_leagues_to_fetch(self) -> set: + """Determine which leagues to fetch for live data based on favorites and map.""" + if self.favorite_teams and self._team_league_map: + leagues_to_fetch = set() + for team in self.favorite_teams: + league = self._team_league_map.get(team) + if league: + leagues_to_fetch.add(league) + else: + self.logger.warning(f"[Soccer Live] Favorite team '{team}' not found in team-league map.") + # Fallback if map lookups fail + if not leagues_to_fetch: + self.logger.warning("[Soccer Live] No leagues found for favorite teams in map. Falling back to configured leagues.") + return set(self.target_leagues_config) + return leagues_to_fetch + else: + # No favorites or no map, use configured leagues + return set(self.target_leagues_config) + def _fetch_data(self, date_str: str = None) -> Optional[Dict]: """Fetch data using shared data mechanism or live fetching per league.""" if isinstance(self, SoccerLiveManager) and not self.test_mode: @@ -211,7 +368,11 @@ class BaseSoccerManager: live_data = {"events": []} today_date_str = datetime.now(timezone.utc).strftime('%Y%m%d') - for league_slug in self.target_leagues: + # Determine leagues to fetch based on favorites and map + leagues_to_fetch = self._get_live_leagues_to_fetch() + self.logger.debug(f"[Soccer Live] Determined leagues to fetch: {leagues_to_fetch}") + + for league_slug in leagues_to_fetch: try: # Check cache first for live data (shorter expiry?) cache_key = f"soccer_live_{league_slug}_{today_date_str}"