From 335ab8cce00a007a1042e171cd4112e116786dc6 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Fri, 12 Sep 2025 13:35:01 -0400 Subject: [PATCH] update leaderboard data sources for other sports and font sizes --- src/leaderboard_manager.py | 383 ++++++++++++++++++++++++++++++---- src/odds_ticker_manager.py | 10 +- test/test_standings_fetch.py | 239 +++++++++++++++++++++ test/test_standings_simple.py | 293 ++++++++++++++++++++++++++ 4 files changed, 878 insertions(+), 47 deletions(-) create mode 100644 test/test_standings_fetch.py create mode 100644 test/test_standings_simple.py diff --git a/src/leaderboard_manager.py b/src/leaderboard_manager.py index 081ec44c..31dbbb8a 100644 --- a/src/leaderboard_manager.py +++ b/src/leaderboard_manager.py @@ -7,9 +7,15 @@ from datetime import datetime, timedelta, timezone import os from PIL import Image, ImageDraw, ImageFont import pytz -from .display_manager import DisplayManager -from .cache_manager import CacheManager -from .config_manager import ConfigManager +try: + from .display_manager import DisplayManager + from .cache_manager import CacheManager + from .config_manager import ConfigManager +except ImportError: + # Fallback for direct imports + from display_manager import DisplayManager + from cache_manager import CacheManager + from config_manager import ConfigManager # Import the API counter function from web interface try: @@ -71,9 +77,12 @@ class LeaderboardManager: 'league': 'nfl', 'logo_dir': 'assets/sports/nfl_logos', 'league_logo': 'assets/sports/nfl_logos/nfl.png', - 'teams_url': 'https://site.api.espn.com/apis/site/v2/sports/football/nfl/teams', + 'standings_url': 'https://site.api.espn.com/apis/v2/sports/football/nfl/standings', 'enabled': self.enabled_sports.get('nfl', {}).get('enabled', False), - 'top_teams': self.enabled_sports.get('nfl', {}).get('top_teams', 10) + 'top_teams': self.enabled_sports.get('nfl', {}).get('top_teams', 10), + 'season': self.enabled_sports.get('nfl', {}).get('season', 2025), + 'level': self.enabled_sports.get('nfl', {}).get('level', 1), + 'sort': self.enabled_sports.get('nfl', {}).get('sort', 'winpercent:desc,gamesbehind:asc') }, 'nba': { 'sport': 'basketball', @@ -89,9 +98,12 @@ class LeaderboardManager: 'league': 'mlb', 'logo_dir': 'assets/sports/mlb_logos', 'league_logo': 'assets/sports/mlb_logos/mlb.png', - 'teams_url': 'https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/teams', + 'standings_url': 'https://site.api.espn.com/apis/v2/sports/baseball/mlb/standings', 'enabled': self.enabled_sports.get('mlb', {}).get('enabled', False), - 'top_teams': self.enabled_sports.get('mlb', {}).get('top_teams', 10) + 'top_teams': self.enabled_sports.get('mlb', {}).get('top_teams', 10), + 'season': self.enabled_sports.get('mlb', {}).get('season', 2025), + 'level': self.enabled_sports.get('mlb', {}).get('level', 1), + 'sort': self.enabled_sports.get('mlb', {}).get('sort', 'winpercent:desc,gamesbehind:asc') }, 'ncaa_fb': { 'sport': 'football', @@ -108,9 +120,12 @@ class LeaderboardManager: 'league': 'nhl', 'logo_dir': 'assets/sports/nhl_logos', 'league_logo': 'assets/sports/nhl_logos/nhl.png', - 'teams_url': 'https://site.api.espn.com/apis/site/v2/sports/hockey/nhl/teams', + 'standings_url': 'https://site.api.espn.com/apis/v2/sports/hockey/nhl/standings', 'enabled': self.enabled_sports.get('nhl', {}).get('enabled', False), - 'top_teams': self.enabled_sports.get('nhl', {}).get('top_teams', 10) + 'top_teams': self.enabled_sports.get('nhl', {}).get('top_teams', 10), + 'season': self.enabled_sports.get('nhl', {}).get('season', 2025), + 'level': self.enabled_sports.get('nhl', {}).get('level', 1), + 'sort': self.enabled_sports.get('nhl', {}).get('sort', 'winpercent:desc,gamesbehind:asc') }, 'ncaam_basketball': { 'sport': 'basketball', @@ -120,6 +135,19 @@ class LeaderboardManager: 'teams_url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/teams', 'enabled': self.enabled_sports.get('ncaam_basketball', {}).get('enabled', False), 'top_teams': self.enabled_sports.get('ncaam_basketball', {}).get('top_teams', 25) + }, + 'ncaa_baseball': { + 'sport': 'baseball', + 'league': 'college-baseball', + 'logo_dir': 'assets/sports/ncaa_fbs_logos', + 'league_logo': 'assets/sports/ncaa_fbs_logos/ncaa_baseball.png', + 'standings_url': 'https://site.api.espn.com/apis/v2/sports/baseball/college-baseball/standings', + 'scoreboard_url': 'https://site.api.espn.com/apis/site/v2/sports/baseball/college-baseball/scoreboard', + 'enabled': self.enabled_sports.get('ncaa_baseball', {}).get('enabled', False), + 'top_teams': self.enabled_sports.get('ncaa_baseball', {}).get('top_teams', 25), + 'season': self.enabled_sports.get('ncaa_baseball', {}).get('season', 2025), + 'level': self.enabled_sports.get('ncaa_baseball', {}).get('level', 1), + 'sort': self.enabled_sports.get('ncaa_baseball', {}).get('sort', 'winpercent:desc,gamesbehind:asc') } } @@ -152,15 +180,17 @@ class LeaderboardManager: try: return { 'small': ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 6), - 'medium': ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8), - 'large': ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) + 'medium': ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10), # Increased from 8 to 10 + 'large': ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12), # Increased from 10 to 12 + 'xlarge': ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 14) # New extra-large font } except Exception as e: logger.error(f"Error loading fonts: {e}") return { 'small': ImageFont.load_default(), 'medium': ImageFont.load_default(), - 'large': ImageFont.load_default() + 'large': ImageFont.load_default(), + 'xlarge': ImageFont.load_default() } def _get_team_logo(self, team_abbr: str, logo_dir: str) -> Optional[Image.Image]: @@ -213,6 +243,10 @@ class LeaderboardManager: if league_key == 'college-football': return self._fetch_ncaa_fb_rankings(league_config) + # Use standings endpoint for NFL, MLB, NHL, and NCAA Baseball + if league_key in ['nfl', 'mlb', 'nhl', 'college-baseball']: + return self._fetch_standings_data(league_config) + try: logger.info(f"Fetching fresh leaderboard data for {league_key}") @@ -391,6 +425,189 @@ class LeaderboardManager: logger.error(f"Error fetching rankings for {league_key}: {e}") return [] + def _fetch_standings_data(self, league_config: Dict[str, Any]) -> List[Dict[str, Any]]: + """Fetch standings data from ESPN API using the standings endpoint.""" + league_key = league_config['league'] + cache_key = f"leaderboard_{league_key}_standings" + + # Try to get cached data first + cached_data = self.cache_manager.get_cached_data_with_strategy(cache_key, 'leaderboard') + if cached_data: + logger.info(f"Using cached standings data for {league_key}") + return cached_data.get('standings', []) + + try: + logger.info(f"Fetching fresh standings data for {league_key}") + + # Build the standings URL with query parameters + standings_url = league_config['standings_url'] + params = { + 'season': league_config.get('season', 2025), + 'level': league_config.get('level', 1), + 'sort': league_config.get('sort', 'winpercent:desc,gamesbehind:asc') + } + + logger.info(f"Fetching standings from: {standings_url} with params: {params}") + + response = requests.get(standings_url, params=params, timeout=self.request_timeout) + response.raise_for_status() + data = response.json() + + # Increment API counter for sports data + increment_api_counter('sports', 1) + + standings = [] + + # Parse the standings data structure + # Check if we have direct standings data or children (divisions/conferences) + if 'standings' in data and 'entries' in data['standings']: + # Direct standings data (e.g., NFL overall standings) + standings_data = data['standings']['entries'] + logger.info(f"Processing direct standings data with {len(standings_data)} teams") + + for entry in standings_data: + team_data = entry.get('team', {}) + stats = entry.get('stats', []) + + team_name = team_data.get('displayName', 'Unknown') + team_abbr = team_data.get('abbreviation', 'Unknown') + + # Extract record from stats + wins = 0 + losses = 0 + ties = 0 + win_percentage = 0.0 + + # First pass: collect all stat values + games_played = 0 + for stat in stats: + stat_type = stat.get('type', '') + stat_value = stat.get('value', 0) + + if stat_type == 'wins': + wins = int(stat_value) + elif stat_type == 'losses': + losses = int(stat_value) + elif stat_type == 'ties': + ties = int(stat_value) + elif stat_type == 'winpercent': + win_percentage = float(stat_value) + # NHL specific stats + elif stat_type == 'overtimelosses' and league_key == 'nhl': + ties = int(stat_value) # NHL uses overtime losses as ties + elif stat_type == 'gamesplayed' and league_key == 'nhl': + games_played = float(stat_value) + + # Second pass: calculate win percentage for NHL if not already set + if league_key == 'nhl' and win_percentage == 0.0 and games_played > 0: + win_percentage = wins / games_played + + # Create record summary + if ties > 0: + record_summary = f"{wins}-{losses}-{ties}" + else: + record_summary = f"{wins}-{losses}" + + standings.append({ + 'name': team_name, + 'abbreviation': team_abbr, + 'wins': wins, + 'losses': losses, + 'ties': ties, + 'win_percentage': win_percentage, + 'record_summary': record_summary, + 'division': 'Overall' + }) + + elif 'children' in data: + # Children structure (divisions/conferences) + children = data.get('children', []) + logger.info(f"Processing {len(children)} divisions/conferences") + + for child in children: + child_name = child.get('displayName', 'Unknown') + logger.info(f"Processing {child_name}") + + standings_data = child.get('standings', {}).get('entries', []) + + for entry in standings_data: + team_data = entry.get('team', {}) + stats = entry.get('stats', []) + + team_name = team_data.get('displayName', 'Unknown') + team_abbr = team_data.get('abbreviation', 'Unknown') + + # Extract record from stats + wins = 0 + losses = 0 + ties = 0 + win_percentage = 0.0 + + # First pass: collect all stat values + games_played = 0 + for stat in stats: + stat_type = stat.get('type', '') + stat_value = stat.get('value', 0) + + if stat_type == 'wins': + wins = int(stat_value) + elif stat_type == 'losses': + losses = int(stat_value) + elif stat_type == 'ties': + ties = int(stat_value) + elif stat_type == 'winpercent': + win_percentage = float(stat_value) + # NHL specific stats + elif stat_type == 'overtimelosses' and league_key == 'nhl': + ties = int(stat_value) # NHL uses overtime losses as ties + elif stat_type == 'gamesplayed' and league_key == 'nhl': + games_played = float(stat_value) + + # Second pass: calculate win percentage for NHL if not already set + if league_key == 'nhl' and win_percentage == 0.0 and games_played > 0: + win_percentage = wins / games_played + + # Create record summary + if ties > 0: + record_summary = f"{wins}-{losses}-{ties}" + else: + record_summary = f"{wins}-{losses}" + + standings.append({ + 'name': team_name, + 'abbreviation': team_abbr, + 'wins': wins, + 'losses': losses, + 'ties': ties, + 'win_percentage': win_percentage, + 'record_summary': record_summary, + 'division': child_name + }) + else: + logger.warning(f"No standings or children data found for {league_key}") + return [] + + # Sort by win percentage (descending) and limit to top teams + standings.sort(key=lambda x: x['win_percentage'], reverse=True) + top_teams = standings[:league_config['top_teams']] + + # Cache the results + cache_data = { + 'standings': top_teams, + 'timestamp': time.time(), + 'league': league_key, + 'season': params['season'], + 'level': params['level'] + } + self.cache_manager.save_cache(cache_key, cache_data) + + logger.info(f"Fetched and cached {len(top_teams)} teams for {league_key} standings") + return top_teams + + except Exception as e: + logger.error(f"Error fetching standings for {league_key}: {e}") + return [] + def _fetch_team_record(self, team_abbr: str, league_config: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Fetch individual team record from ESPN API with caching.""" league = league_config['league'] @@ -592,11 +809,11 @@ class LeaderboardManager: # For other leagues, show position number_text = f"{i+1}." - number_bbox = self.fonts['large'].getbbox(number_text) + number_bbox = self.fonts['xlarge'].getbbox(number_text) number_width = number_bbox[2] - number_bbox[0] number_height = number_bbox[3] - number_bbox[1] number_y = (height - number_height) // 2 - draw.text((team_x, number_y), number_text, font=self.fonts['large'], fill=(255, 255, 0)) + draw.text((team_x, number_y), number_text, font=self.fonts['xlarge'], fill=(255, 255, 0)) # Draw team logo (95% of display height, centered vertically) team_logo = self._get_team_logo(team['abbreviation'], league_config['logo_dir']) @@ -611,24 +828,24 @@ class LeaderboardManager: # Draw team abbreviation after the logo (centered vertically) team_text = team['abbreviation'] - text_bbox = self.fonts['medium'].getbbox(team_text) + text_bbox = self.fonts['large'].getbbox(team_text) text_width = text_bbox[2] - text_bbox[0] text_height = text_bbox[3] - text_bbox[1] text_x = logo_x + logo_size + 4 text_y = (height - text_height) // 2 - draw.text((text_x, text_y), team_text, font=self.fonts['medium'], fill=(255, 255, 255)) + draw.text((text_x, text_y), team_text, font=self.fonts['large'], fill=(255, 255, 255)) # Calculate total width used by this team team_width = number_width + 4 + logo_size + 4 + text_width + 12 # 12px spacing to next team else: # Fallback if no logo - draw team abbreviation after bold number (centered vertically) team_text = team['abbreviation'] - text_bbox = self.fonts['medium'].getbbox(team_text) + text_bbox = self.fonts['large'].getbbox(team_text) text_width = text_bbox[2] - text_bbox[0] text_height = text_bbox[3] - text_bbox[1] text_x = team_x + number_width + 4 text_y = (height - text_height) // 2 - draw.text((text_x, text_y), team_text, font=self.fonts['medium'], fill=(255, 255, 255)) + draw.text((text_x, text_y), team_text, font=self.fonts['large'], fill=(255, 255, 255)) # Calculate total width used by this team team_width = number_width + 4 + text_width + 12 # 12px spacing to next team @@ -640,37 +857,119 @@ class LeaderboardManager: current_x += teams_width + 20 # Teams width + spacing current_x += spacing # Add spacing between leagues - # Calculate dynamic duration based on number of teams and content - if self.dynamic_duration_enabled: - # Count total teams across all leagues - total_teams = sum(len(league_data['teams']) for league_data in self.leaderboard_data) - total_leagues = len(self.leaderboard_data) - - # Use configurable time per team and league - time_per_team = self.time_per_team - time_per_league = self.time_per_league - - # Calculate base duration from teams and leagues - base_duration = (total_teams * time_per_team) + (total_leagues * time_per_league) - - # Add scroll time based on content width - scroll_time = (total_width / self.scroll_speed) * self.scroll_delay - - # Use the maximum of base duration and scroll time, with buffer - calculated_duration = max(base_duration, scroll_time) + self.duration_buffer - - # Apply min/max constraints - self.dynamic_duration = max(self.min_duration, min(self.max_duration, calculated_duration)) - - logger.info(f"Calculated dynamic duration: {self.dynamic_duration:.1f}s for {total_teams} teams across {total_leagues} leagues (width: {total_width})") - + # Set total scroll width for dynamic duration calculation self.total_scroll_width = total_width + + # Calculate dynamic duration using proper scroll-based calculation + if self.dynamic_duration_enabled: + self.calculate_dynamic_duration() logger.info(f"Created leaderboard image with width {total_width}") except Exception as e: logger.error(f"Error creating leaderboard image: {e}") self.leaderboard_image = None + def calculate_dynamic_duration(self): + """Calculate the exact time needed to display all leaderboard content""" + logger.debug(f"calculate_dynamic_duration called - dynamic_duration_enabled: {self.dynamic_duration_enabled}, total_scroll_width: {self.total_scroll_width}") + + # If dynamic duration is disabled, use fixed duration from config + if not self.dynamic_duration_enabled: + self.dynamic_duration = self.leaderboard_config.get('display_duration', 60) + logger.debug(f"Dynamic duration disabled, using fixed duration: {self.dynamic_duration}s") + return + + if not self.total_scroll_width: + self.dynamic_duration = self.min_duration # Use configured minimum + logger.debug(f"total_scroll_width is 0, using minimum duration: {self.min_duration}s") + return + + try: + # Get display width (assume full width of display) + display_width = getattr(self.display_manager, 'matrix', None) + if display_width: + display_width = display_width.width + else: + display_width = 128 # Default to 128 if not available + + # Calculate total scroll distance needed + # For leaderboard, we need to scroll the entire content width plus display width + # to ensure all content is visible from start to finish + total_scroll_distance = display_width + self.total_scroll_width + + # Calculate time based on scroll speed and delay + # scroll_speed = pixels per frame, scroll_delay = seconds per frame + frames_needed = total_scroll_distance / self.scroll_speed + total_time = frames_needed * self.scroll_delay + + # Add buffer time for smooth cycling (configurable %) + buffer_time = total_time * self.duration_buffer + + # If looping is enabled, ensure we complete at least one full cycle + # and add extra time to ensure we don't cut off mid-scroll + if self.loop: + # Add extra buffer for looping to ensure smooth transition + # Use a more reasonable buffer to prevent excessive looping + loop_buffer = total_time * 0.15 # 15% extra for looping (reduced from 30%) + calculated_duration = int(total_time + buffer_time + loop_buffer) + logger.debug(f"Looping enabled, added {loop_buffer:.2f}s loop buffer") + else: + # Even without looping, add extra buffer to ensure complete display + extra_buffer = total_time * 0.1 # 10% extra to ensure complete content display (reduced from 15%) + calculated_duration = int(total_time + buffer_time + extra_buffer) + logger.debug(f"No looping, added {extra_buffer:.2f}s extra buffer for complete display") + + # Apply configured min/max limits + if calculated_duration < self.min_duration: + self.dynamic_duration = self.min_duration + logger.debug(f"Duration capped to minimum: {self.min_duration}s") + elif calculated_duration > self.max_duration: + self.dynamic_duration = self.max_duration + logger.debug(f"Duration capped to maximum: {self.max_duration}s") + else: + self.dynamic_duration = calculated_duration + + # Additional safety check: if the calculated duration seems too short for the content, + # ensure we have enough time to display all content properly + if self.dynamic_duration < 30 and self.total_scroll_width > 800: + # If we have a lot of content but short duration, increase it + self.dynamic_duration = max(30, int(self.total_scroll_width / 30)) # At least 30s or 1s per 30px + logger.debug(f"Adjusted duration for large content: {self.dynamic_duration}s") + + logger.debug(f"Leaderboard dynamic duration calculation:") + logger.debug(f" Display width: {display_width}px") + logger.debug(f" Content width: {self.total_scroll_width}px") + logger.debug(f" Total scroll distance: {total_scroll_distance}px") + logger.debug(f" Scroll speed: {self.scroll_speed}px/frame") + logger.debug(f" Scroll delay: {self.scroll_delay}s/frame") + logger.debug(f" Frames needed: {frames_needed:.1f}") + logger.debug(f" Base time: {total_time:.2f}s") + logger.debug(f" Buffer time: {buffer_time:.2f}s ({self.duration_buffer*100}%)") + logger.debug(f" Looping enabled: {self.loop}") + logger.debug(f" Calculated duration: {calculated_duration}s") + logger.debug(f" Final duration: {self.dynamic_duration}s") + + except Exception as e: + logger.error(f"Error calculating dynamic duration: {e}") + self.dynamic_duration = self.min_duration # Use configured minimum as fallback + + def get_dynamic_duration(self) -> int: + """Get the calculated dynamic duration for display""" + # If we don't have a valid dynamic duration yet (total_scroll_width is 0), + # try to update the data first + if self.total_scroll_width == 0 and self.is_enabled: + logger.debug("get_dynamic_duration called but total_scroll_width is 0, attempting update...") + try: + # Force an update to get the data and calculate proper duration + # Bypass the update interval check for duration calculation + self.update() + logger.debug(f"Force update completed, total_scroll_width: {self.total_scroll_width}px") + except Exception as e: + logger.error(f"Error updating leaderboard for dynamic duration: {e}") + + logger.debug(f"get_dynamic_duration called, returning: {self.dynamic_duration}s") + return self.dynamic_duration + def update(self) -> None: """Update leaderboard data.""" current_time = time.time() diff --git a/src/odds_ticker_manager.py b/src/odds_ticker_manager.py index 1b514401..684d8cb6 100644 --- a/src/odds_ticker_manager.py +++ b/src/odds_ticker_manager.py @@ -1349,13 +1349,13 @@ class OddsTickerManager: # and add extra time to ensure we don't cut off mid-scroll if self.loop: # Add extra buffer for looping to ensure smooth transition - # Use a larger buffer to ensure complete content display - loop_buffer = total_time * 0.3 # 30% extra for looping (increased from 20%) + # Use a more reasonable buffer to prevent excessive looping + loop_buffer = total_time * 0.15 # 15% extra for looping (reduced from 30%) calculated_duration = int(total_time + buffer_time + loop_buffer) logger.debug(f"Looping enabled, added {loop_buffer:.2f}s loop buffer") else: # Even without looping, add extra buffer to ensure complete display - extra_buffer = total_time * 0.15 # 15% extra to ensure complete content display + extra_buffer = total_time * 0.1 # 10% extra to ensure complete content display (reduced from 15%) calculated_duration = int(total_time + buffer_time + extra_buffer) logger.debug(f"No looping, added {extra_buffer:.2f}s extra buffer for complete display") @@ -1371,9 +1371,9 @@ class OddsTickerManager: # Additional safety check: if the calculated duration seems too short for the content, # ensure we have enough time to display all content properly - if self.dynamic_duration < 45 and self.total_scroll_width > 500: + if self.dynamic_duration < 30 and self.total_scroll_width > 800: # If we have a lot of content but short duration, increase it - self.dynamic_duration = max(45, int(self.total_scroll_width / 20)) # At least 45s or 1s per 20px + self.dynamic_duration = max(30, int(self.total_scroll_width / 30)) # At least 30s or 1s per 30px logger.debug(f"Adjusted duration for large content: {self.dynamic_duration}s") logger.debug(f"Odds ticker dynamic duration calculation:") diff --git a/test/test_standings_fetch.py b/test/test_standings_fetch.py new file mode 100644 index 00000000..3b5d7b80 --- /dev/null +++ b/test/test_standings_fetch.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +""" +Test script to verify the standings fetching logic works correctly. +This tests the core functionality without requiring the full LED matrix setup. +""" + +import requests +import json +import time +from typing import Dict, Any, List + +def fetch_standings_data(league_config: Dict[str, Any]) -> List[Dict[str, Any]]: + """Fetch standings data from ESPN API using the standings endpoint.""" + league_key = league_config['league'] + + try: + print(f"Fetching fresh standings data for {league_key}") + + # Build the standings URL with query parameters + standings_url = league_config['standings_url'] + params = { + 'season': league_config.get('season', 2024), + 'level': league_config.get('level', 1), + 'sort': league_config.get('sort', 'winpercent:desc,gamesbehind:asc') + } + + print(f"Fetching standings from: {standings_url} with params: {params}") + + response = requests.get(standings_url, params=params, timeout=30) + response.raise_for_status() + data = response.json() + + standings = [] + + # Parse the standings data structure + # Check if we have direct standings data or children (divisions/conferences) + if 'standings' in data and 'entries' in data['standings']: + # Direct standings data (e.g., NFL overall standings) + standings_data = data['standings']['entries'] + print(f"Processing direct standings data with {len(standings_data)} teams") + + for entry in standings_data: + team_data = entry.get('team', {}) + stats = entry.get('stats', []) + + team_name = team_data.get('displayName', 'Unknown') + team_abbr = team_data.get('abbreviation', 'Unknown') + + # Extract record from stats + wins = 0 + losses = 0 + ties = 0 + win_percentage = 0.0 + + for stat in stats: + stat_type = stat.get('type', '') + stat_value = stat.get('value', 0) + + if stat_type == 'wins': + wins = int(stat_value) + elif stat_type == 'losses': + losses = int(stat_value) + elif stat_type == 'ties': + ties = int(stat_value) + elif stat_type == 'winpercent': + win_percentage = float(stat_value) + + # Create record summary + if ties > 0: + record_summary = f"{wins}-{losses}-{ties}" + else: + record_summary = f"{wins}-{losses}" + + standings.append({ + 'name': team_name, + 'abbreviation': team_abbr, + 'wins': wins, + 'losses': losses, + 'ties': ties, + 'win_percentage': win_percentage, + 'record_summary': record_summary, + 'division': 'Overall' + }) + + elif 'children' in data: + # Children structure (divisions/conferences) + children = data.get('children', []) + print(f"Processing {len(children)} divisions/conferences") + + for child in children: + child_name = child.get('displayName', 'Unknown') + print(f"Processing {child_name}") + + standings_data = child.get('standings', {}).get('entries', []) + + for entry in standings_data: + team_data = entry.get('team', {}) + stats = entry.get('stats', []) + + team_name = team_data.get('displayName', 'Unknown') + team_abbr = team_data.get('abbreviation', 'Unknown') + + # Extract record from stats + wins = 0 + losses = 0 + ties = 0 + win_percentage = 0.0 + + for stat in stats: + stat_type = stat.get('type', '') + stat_value = stat.get('value', 0) + + if stat_type == 'wins': + wins = int(stat_value) + elif stat_type == 'losses': + losses = int(stat_value) + elif stat_type == 'ties': + ties = int(stat_value) + elif stat_type == 'winpercent': + win_percentage = float(stat_value) + + # Create record summary + if ties > 0: + record_summary = f"{wins}-{losses}-{ties}" + else: + record_summary = f"{wins}-{losses}" + + standings.append({ + 'name': team_name, + 'abbreviation': team_abbr, + 'wins': wins, + 'losses': losses, + 'ties': ties, + 'win_percentage': win_percentage, + 'record_summary': record_summary, + 'division': child_name + }) + else: + print(f"No standings or children data found for {league_key}") + return [] + + # Sort by win percentage (descending) and limit to top teams + standings.sort(key=lambda x: x['win_percentage'], reverse=True) + top_teams = standings[:league_config['top_teams']] + + print(f"Fetched and processed {len(top_teams)} teams for {league_key} standings") + return top_teams + + except Exception as e: + print(f"Error fetching standings for {league_key}: {e}") + return [] + +def test_standings_fetch(): + """Test the standings fetching functionality.""" + print("Testing Standings Fetching Logic") + print("=" * 50) + + # Test configurations + test_configs = [ + { + 'name': 'NFL', + 'config': { + 'league': 'nfl', + 'standings_url': 'https://site.api.espn.com/apis/v2/sports/football/nfl/standings', + 'top_teams': 5, + 'season': 2025, + 'level': 1, + 'sort': 'winpercent:desc,gamesbehind:asc' + } + }, + { + 'name': 'MLB', + 'config': { + 'league': 'mlb', + 'standings_url': 'https://site.api.espn.com/apis/v2/sports/baseball/mlb/standings', + 'top_teams': 5, + 'season': 2025, + 'level': 1, + 'sort': 'winpercent:desc,gamesbehind:asc' + } + }, + { + 'name': 'NHL', + 'config': { + 'league': 'nhl', + 'standings_url': 'https://site.api.espn.com/apis/v2/sports/hockey/nhl/standings', + 'top_teams': 5, + 'season': 2025, + 'level': 1, + 'sort': 'winpercent:desc,gamesbehind:asc' + } + }, + { + 'name': 'NCAA Baseball', + 'config': { + 'league': 'college-baseball', + 'standings_url': 'https://site.api.espn.com/apis/v2/sports/baseball/college-baseball/standings', + 'top_teams': 5, + 'season': 2025, + 'level': 1, + 'sort': 'winpercent:desc,gamesbehind:asc' + } + } + ] + + results = [] + + for test_config in test_configs: + print(f"\n--- Testing {test_config['name']} ---") + + standings = fetch_standings_data(test_config['config']) + + if standings: + print(f"✓ Successfully fetched {len(standings)} teams") + print(f"Top {len(standings)} teams:") + for i, team in enumerate(standings): + print(f" {i+1}. {team['name']} ({team['abbreviation']}): {team['record_summary']} ({team['win_percentage']:.3f})") + results.append(True) + else: + print(f"✗ Failed to fetch standings for {test_config['name']}") + results.append(False) + + # Summary + passed = sum(results) + total = len(results) + + print(f"\n=== Test Results ===") + print(f"Passed: {passed}/{total}") + + if passed == total: + print("✓ All standings fetch tests passed!") + return True + else: + print("✗ Some tests failed!") + return False + +if __name__ == "__main__": + success = test_standings_fetch() + exit(0 if success else 1) diff --git a/test/test_standings_simple.py b/test/test_standings_simple.py new file mode 100644 index 00000000..bde287de --- /dev/null +++ b/test/test_standings_simple.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +""" +Simple test script to verify the ESPN standings endpoints work correctly. +""" + +import requests +import json + +def test_nfl_standings(): + """Test NFL standings endpoint with corrected parsing.""" + print("\n=== Testing NFL Standings ===") + + url = "https://site.api.espn.com/apis/v2/sports/football/nfl/standings" + params = { + 'season': 2025, + 'level': 1, + 'sort': 'winpercent:desc,gamesbehind:asc' + } + + try: + response = requests.get(url, params=params, timeout=30) + response.raise_for_status() + data = response.json() + + print(f"✓ Successfully fetched NFL standings") + + # Check for direct standings data + if 'standings' in data and 'entries' in data['standings']: + standings_data = data['standings']['entries'] + print(f" Found {len(standings_data)} teams in direct standings") + + # Show top 5 teams + print(f" Top 5 teams:") + for i, entry in enumerate(standings_data[:5]): + team_data = entry.get('team', {}) + team_name = team_data.get('displayName', 'Unknown') + team_abbr = team_data.get('abbreviation', 'Unknown') + + # Get record + wins = 0 + losses = 0 + ties = 0 + win_percentage = 0.0 + + for stat in entry.get('stats', []): + stat_type = stat.get('type', '') + stat_value = stat.get('value', 0) + + if stat_type == 'wins': + wins = int(stat_value) + elif stat_type == 'losses': + losses = int(stat_value) + elif stat_type == 'ties': + ties = int(stat_value) + elif stat_type == 'winpercent': + win_percentage = float(stat_value) + + record = f"{wins}-{losses}" if ties == 0 else f"{wins}-{losses}-{ties}" + print(f" {i+1}. {team_name} ({team_abbr}): {record} ({win_percentage:.3f})") + + return True + else: + print(" ✗ No direct standings data found") + return False + + except Exception as e: + print(f"✗ Error testing NFL standings: {e}") + return False + +def test_mlb_standings(): + """Test MLB standings endpoint with corrected parsing.""" + print("\n=== Testing MLB Standings ===") + + url = "https://site.api.espn.com/apis/v2/sports/baseball/mlb/standings" + params = { + 'season': 2025, + 'level': 1, + 'sort': 'winpercent:desc,gamesbehind:asc' + } + + try: + response = requests.get(url, params=params, timeout=30) + response.raise_for_status() + data = response.json() + + print(f"✓ Successfully fetched MLB standings") + + # Check for direct standings data + if 'standings' in data and 'entries' in data['standings']: + standings_data = data['standings']['entries'] + print(f" Found {len(standings_data)} teams in direct standings") + + # Show top 5 teams + print(f" Top 5 teams:") + for i, entry in enumerate(standings_data[:5]): + team_data = entry.get('team', {}) + team_name = team_data.get('displayName', 'Unknown') + team_abbr = team_data.get('abbreviation', 'Unknown') + + # Get record + wins = 0 + losses = 0 + ties = 0 + win_percentage = 0.0 + + for stat in entry.get('stats', []): + stat_type = stat.get('type', '') + stat_value = stat.get('value', 0) + + if stat_type == 'wins': + wins = int(stat_value) + elif stat_type == 'losses': + losses = int(stat_value) + elif stat_type == 'ties': + ties = int(stat_value) + elif stat_type == 'winpercent': + win_percentage = float(stat_value) + + record = f"{wins}-{losses}" if ties == 0 else f"{wins}-{losses}-{ties}" + print(f" {i+1}. {team_name} ({team_abbr}): {record} ({win_percentage:.3f})") + + return True + else: + print(" ✗ No direct standings data found") + return False + + except Exception as e: + print(f"✗ Error testing MLB standings: {e}") + return False + +def test_nhl_standings(): + """Test NHL standings endpoint with corrected parsing.""" + print("\n=== Testing NHL Standings ===") + + url = "https://site.api.espn.com/apis/v2/sports/hockey/nhl/standings" + params = { + 'season': 2025, + 'level': 1, + 'sort': 'winpercent:desc,gamesbehind:asc' + } + + try: + response = requests.get(url, params=params, timeout=30) + response.raise_for_status() + data = response.json() + + print(f"✓ Successfully fetched NHL standings") + + # Check for direct standings data + if 'standings' in data and 'entries' in data['standings']: + standings_data = data['standings']['entries'] + print(f" Found {len(standings_data)} teams in direct standings") + + # Show top 5 teams + print(f" Top 5 teams:") + for i, entry in enumerate(standings_data[:5]): + team_data = entry.get('team', {}) + team_name = team_data.get('displayName', 'Unknown') + team_abbr = team_data.get('abbreviation', 'Unknown') + + # Get record with NHL-specific parsing + wins = 0 + losses = 0 + ties = 0 + win_percentage = 0.0 + games_played = 0 + + # First pass: collect all stat values + for stat in entry.get('stats', []): + stat_type = stat.get('type', '') + stat_value = stat.get('value', 0) + + if stat_type == 'wins': + wins = int(stat_value) + elif stat_type == 'losses': + losses = int(stat_value) + elif stat_type == 'ties': + ties = int(stat_value) + elif stat_type == 'winpercent': + win_percentage = float(stat_value) + # NHL specific stats + elif stat_type == 'overtimelosses': + ties = int(stat_value) # NHL uses overtime losses as ties + elif stat_type == 'gamesplayed': + games_played = float(stat_value) + + # Second pass: calculate win percentage for NHL if not already set + if win_percentage == 0.0 and games_played > 0: + win_percentage = wins / games_played + + record = f"{wins}-{losses}" if ties == 0 else f"{wins}-{losses}-{ties}" + print(f" {i+1}. {team_name} ({team_abbr}): {record} ({win_percentage:.3f})") + + return True + else: + print(" ✗ No direct standings data found") + return False + + except Exception as e: + print(f"✗ Error testing NHL standings: {e}") + return False + +def test_ncaa_baseball_standings(): + """Test NCAA Baseball standings endpoint with corrected parsing.""" + print("\n=== Testing NCAA Baseball Standings ===") + + url = "https://site.api.espn.com/apis/v2/sports/baseball/college-baseball/standings" + params = { + 'season': 2025, + 'level': 1, + 'sort': 'winpercent:desc,gamesbehind:asc' + } + + try: + response = requests.get(url, params=params, timeout=30) + response.raise_for_status() + data = response.json() + + print(f"✓ Successfully fetched NCAA Baseball standings") + + # Check for direct standings data + if 'standings' in data and 'entries' in data['standings']: + standings_data = data['standings']['entries'] + print(f" Found {len(standings_data)} teams in direct standings") + + # Show top 5 teams + print(f" Top 5 teams:") + for i, entry in enumerate(standings_data[:5]): + team_data = entry.get('team', {}) + team_name = team_data.get('displayName', 'Unknown') + team_abbr = team_data.get('abbreviation', 'Unknown') + + # Get record + wins = 0 + losses = 0 + ties = 0 + win_percentage = 0.0 + + for stat in entry.get('stats', []): + stat_type = stat.get('type', '') + stat_value = stat.get('value', 0) + + if stat_type == 'wins': + wins = int(stat_value) + elif stat_type == 'losses': + losses = int(stat_value) + elif stat_type == 'ties': + ties = int(stat_value) + elif stat_type == 'winpercent': + win_percentage = float(stat_value) + + record = f"{wins}-{losses}" if ties == 0 else f"{wins}-{losses}-{ties}" + print(f" {i+1}. {team_name} ({team_abbr}): {record} ({win_percentage:.3f})") + + return True + else: + print(" ✗ No direct standings data found") + return False + + except Exception as e: + print(f"✗ Error testing NCAA Baseball standings: {e}") + return False + +def main(): + """Main function to run all tests.""" + print("ESPN Standings Endpoints Test (Corrected)") + print("=" * 50) + + results = [] + + # Test individual endpoints + results.append(test_nfl_standings()) + results.append(test_mlb_standings()) + results.append(test_nhl_standings()) + results.append(test_ncaa_baseball_standings()) + + # Summary + passed = sum(results) + total = len(results) + + print(f"\n=== Test Results ===") + print(f"Passed: {passed}/{total}") + + if passed == total: + print("✓ All tests passed!") + return True + else: + print("✗ Some tests failed!") + return False + +if __name__ == "__main__": + success = main() + exit(0 if success else 1)