Consolidate Baseball leagues to completely use Baseball class (#84)

* Consolidate MLB to completely use Baseball class

* typos

* add OT period number

* Add new live class and abstracts

* NCAA BB is consolidated

* MLB Working

* NCAA Hockey and NHL working

* didn't need wrapper function

* Add hockey shots on goal

* cleanup

---------

Co-authored-by: Alex Resnick <adr8282@gmail.com>
This commit is contained in:
Alex Resnick
2025-09-30 16:55:45 -05:00
committed by GitHub
parent e584026bda
commit 2d6c238ea0
9 changed files with 1480 additions and 3838 deletions

View File

@@ -5,34 +5,36 @@ This module provides baseball-specific base classes that extend the core sports
with baseball-specific logic for innings, outs, bases, strikes, balls, etc. with baseball-specific logic for innings, outs, bases, strikes, balls, etc.
""" """
from typing import Dict, Any, Optional, List
from src.base_classes.sports import SportsCore
from src.base_classes.api_extractors import ESPNBaseballExtractor
from src.base_classes.data_sources import ESPNDataSource, MLBAPIDataSource
import logging import logging
import random
import time
from typing import Any, Dict, Optional
from PIL import Image, ImageDraw
from src.base_classes.data_sources import ESPNDataSource
from src.base_classes.sports import SportsCore, SportsLive
class Baseball(SportsCore): class Baseball(SportsCore):
"""Base class for baseball sports with common functionality.""" """Base class for baseball sports with common functionality."""
def __init__(
def __init__(self, config: Dict[str, Any], display_manager, cache_manager, logger: logging.Logger, sport_key: str): self,
config: Dict[str, Any],
display_manager,
cache_manager,
logger: logging.Logger,
sport_key: str,
):
super().__init__(config, display_manager, cache_manager, logger, sport_key) super().__init__(config, display_manager, cache_manager, logger, sport_key)
# Initialize baseball-specific architecture components
self.api_extractor = ESPNBaseballExtractor(logger)
# Choose data source based on sport (MLB uses MLB API, others use ESPN)
if sport_key == 'mlb':
self.data_source = MLBAPIDataSource(logger)
else:
self.data_source = ESPNDataSource(logger)
# Baseball-specific configuration # Baseball-specific configuration
self.show_innings = self.mode_config.get("show_innings", True) self.show_innings = self.mode_config.get("show_innings", True)
self.show_outs = self.mode_config.get("show_outs", True) self.show_outs = self.mode_config.get("show_outs", True)
self.show_bases = self.mode_config.get("show_bases", True) self.show_bases = self.mode_config.get("show_bases", True)
self.show_count = self.mode_config.get("show_count", True) self.show_count = self.mode_config.get("show_count", True)
self.show_pitcher_batter = self.mode_config.get("show_pitcher_batter", False) self.show_pitcher_batter = self.mode_config.get("show_pitcher_batter", False)
self.data_source = ESPNDataSource(logger)
self.sport = "baseball" self.sport = "baseball"
def _get_baseball_display_text(self, game: Dict) -> str: def _get_baseball_display_text(self, game: Dict) -> str:
@@ -42,33 +44,33 @@ class Baseball(SportsCore):
# Inning information # Inning information
if self.show_innings: if self.show_innings:
inning = game.get('inning', '') inning = game.get("inning", "")
if inning: if inning:
display_parts.append(f"Inning: {inning}") display_parts.append(f"Inning: {inning}")
# Outs information # Outs information
if self.show_outs: if self.show_outs:
outs = game.get('outs', 0) outs = game.get("outs", 0)
if outs is not None: if outs is not None:
display_parts.append(f"Outs: {outs}") display_parts.append(f"Outs: {outs}")
# Bases information # Bases information
if self.show_bases: if self.show_bases:
bases = game.get('bases', '') bases = game.get("bases", "")
if bases: if bases:
display_parts.append(f"Bases: {bases}") display_parts.append(f"Bases: {bases}")
# Count information # Count information
if self.show_count: if self.show_count:
strikes = game.get('strikes', 0) strikes = game.get("strikes", 0)
balls = game.get('balls', 0) balls = game.get("balls", 0)
if strikes is not None and balls is not None: if strikes is not None and balls is not None:
display_parts.append(f"Count: {balls}-{strikes}") display_parts.append(f"Count: {balls}-{strikes}")
# Pitcher/Batter information # Pitcher/Batter information
if self.show_pitcher_batter: if self.show_pitcher_batter:
pitcher = game.get('pitcher', '') pitcher = game.get("pitcher", "")
batter = game.get('batter', '') batter = game.get("batter", "")
if pitcher: if pitcher:
display_parts.append(f"Pitcher: {pitcher}") display_parts.append(f"Pitcher: {pitcher}")
if batter: if batter:
@@ -84,13 +86,13 @@ class Baseball(SportsCore):
"""Check if a baseball game is currently live.""" """Check if a baseball game is currently live."""
try: try:
# Check if game is marked as live # Check if game is marked as live
is_live = game.get('is_live', False) is_live = game.get("is_live", False)
if is_live: if is_live:
return True return True
# Check inning to determine if game is active # Check inning to determine if game is active
inning = game.get('inning', '') inning = game.get("inning", "")
if inning and inning != 'Final': if inning and inning != "Final":
return True return True
return False return False
@@ -102,17 +104,17 @@ class Baseball(SportsCore):
def _get_baseball_game_status(self, game: Dict) -> str: def _get_baseball_game_status(self, game: Dict) -> str:
"""Get baseball-specific game status.""" """Get baseball-specific game status."""
try: try:
status = game.get('status_text', '') status = game.get("status_text", "")
inning = game.get('inning', '') inning = game.get("inning", "")
if self._is_baseball_game_live(game): if self._is_baseball_game_live(game):
if inning: if inning:
return f"Live - {inning}" return f"Live - {inning}"
else: else:
return "Live" return "Live"
elif game.get('is_final', False): elif game.get("is_final", False):
return "Final" return "Final"
elif game.get('is_upcoming', False): elif game.get("is_upcoming", False):
return "Upcoming" return "Upcoming"
else: else:
return status return status
@@ -121,26 +123,558 @@ class Baseball(SportsCore):
self.logger.error(f"Error getting baseball game status: {e}") self.logger.error(f"Error getting baseball game status: {e}")
return "" return ""
def _extract_game_details(self, game_event: Dict) -> Optional[Dict]:
"""Extract relevant game details from ESPN NCAA FB API response."""
details, home_team, away_team, status, situation = (
self._extract_game_details_common(game_event)
)
if details is None or home_team is None or away_team is None or status is None:
return
try:
# print(status["type"]["state"])
# exit()
game_status = status["type"]["name"].lower()
status_state = status["type"]["state"].lower()
# Get team abbreviations
home_abbr = home_team["team"]["abbreviation"]
away_abbr = away_team["team"]["abbreviation"]
class BaseballLive(Baseball): # Check if this is a favorite team game
is_favorite_game = (
home_abbr in self.favorite_teams or away_abbr in self.favorite_teams
)
# Log all teams found for debugging
self.logger.debug(
f"Found game: {away_abbr} @ {home_abbr} (Status: {game_status}, State: {status_state})"
)
# Only log detailed information for favorite teams
if is_favorite_game:
self.logger.debug(f"Full status data: {game_event['status']}")
self.logger.debug(f"Status type: {game_status}, State: {status_state}")
self.logger.debug(f"Status detail: {status['type'].get('detail', '')}")
self.logger.debug(
f"Status shortDetail: {status['type'].get('shortDetail', '')}"
)
# Get game state information
if status_state == "in":
# For live games, get detailed state
inning = game_event["status"].get(
"period", 1
) # Get inning from status period
# Get inning information from status
status_detail = status["type"].get("detail", "").lower()
status_short = status["type"].get("shortDetail", "").lower()
if is_favorite_game:
self.logger.debug(
f"Raw status detail: {status['type'].get('detail')}"
)
self.logger.debug(
f"Raw status short: {status['type'].get('shortDetail')}"
)
# Determine inning half from status information
inning_half = "top" # Default
# Handle end of inning: next inning is top
if "end" in status_detail or "end" in status_short:
inning_half = "top"
inning = (
game_event["status"].get("period", 1) + 1
) # Use period and increment for next inning
if is_favorite_game:
self.logger.debug(
f"Detected end of inning. Setting to Top {inning}"
)
# Handle middle of inning: next is bottom of current inning
elif "mid" in status_detail or "mid" in status_short:
inning_half = "bottom"
if is_favorite_game:
self.logger.debug(
f"Detected middle of inning. Setting to Bottom {inning}"
)
# Handle bottom of inning
elif (
"bottom" in status_detail
or "bot" in status_detail
or "bottom" in status_short
or "bot" in status_short
):
inning_half = "bottom"
if is_favorite_game:
self.logger.debug(f"Detected bottom of inning: {inning}")
# Handle top of inning
elif "top" in status_detail or "top" in status_short:
inning_half = "top"
if is_favorite_game:
self.logger.debug(f"Detected top of inning: {inning}")
if is_favorite_game:
self.logger.debug(f"Status detail: {status_detail}")
self.logger.debug(f"Status short: {status_short}")
self.logger.debug(f"Determined inning: {inning_half} {inning}")
# Get count and bases from situation
situation = game_event["competitions"][0].get("situation", {})
if is_favorite_game:
self.logger.debug(f"Full situation data: {situation}")
# Get count from the correct location in the API response
count = situation.get("count", {})
balls = count.get("balls", 0)
strikes = count.get("strikes", 0)
outs = situation.get("outs", 0)
# Add detailed logging for favorite team games
if is_favorite_game:
self.logger.debug(f"Full situation data: {situation}")
self.logger.debug(f"Count object: {count}")
self.logger.debug(
f"Raw count values - balls: {balls}, strikes: {strikes}"
)
self.logger.debug(f"Raw outs value: {outs}")
# Try alternative locations for count data
if balls == 0 and strikes == 0:
# First try the summary field
if "summary" in situation:
try:
count_summary = situation["summary"]
balls, strikes = map(int, count_summary.split("-"))
if is_favorite_game:
self.logger.debug(
f"Using summary count: {count_summary}"
)
except (ValueError, AttributeError):
if is_favorite_game:
self.logger.debug("Could not parse summary count")
else:
# Check if count is directly in situation
balls = situation.get("balls", 0)
strikes = situation.get("strikes", 0)
if is_favorite_game:
self.logger.debug(
f"Using direct situation count: balls={balls}, strikes={strikes}"
)
self.logger.debug(
f"Full situation keys: {list(situation.keys())}"
)
if is_favorite_game:
self.logger.debug(f"Final count: balls={balls}, strikes={strikes}")
# Get base runners
bases_occupied = [
situation.get("onFirst", False),
situation.get("onSecond", False),
situation.get("onThird", False),
]
if is_favorite_game:
self.logger.debug(f"Bases occupied: {bases_occupied}")
else:
# Default values for non-live games
inning = 1
inning_half = "top"
balls = 0
strikes = 0
outs = 0
bases_occupied = [False, False, False]
details.update(
{
"status": game_status,
"status_state": status_state,
"inning": inning,
"inning_half": inning_half,
"balls": balls,
"strikes": strikes,
"outs": outs,
"bases_occupied": bases_occupied,
"start_time": game_event["date"],
}
)
# Basic validation (can be expanded)
if not details["home_abbr"] or not details["away_abbr"]:
self.logger.warning(
f"Missing team abbreviation in event: {details['id']}"
)
return None
self.logger.debug(
f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}"
)
return details
except Exception as e:
# Log the problematic event structure if possible
self.logger.error(
f"Error extracting game details: {e} from event: {game_event.get('id')}",
exc_info=True,
)
return None
class BaseballLive(Baseball, SportsLive):
"""Base class for live baseball games.""" """Base class for live baseball games."""
def __init__(self, config: Dict[str, Any], display_manager, cache_manager, logger: logging.Logger, sport_key: str): def __init__(
self,
config: Dict[str, Any],
display_manager,
cache_manager,
logger: logging.Logger,
sport_key: str,
):
super().__init__(config, display_manager, cache_manager, logger, sport_key) super().__init__(config, display_manager, cache_manager, logger, sport_key)
self.logger.info(f"{sport_key.upper()} Live Manager initialized")
def _should_show_baseball_game(self, game: Dict) -> bool: def _test_mode_update(self):
"""Determine if a baseball game should be shown.""" if self.current_game and self.current_game["is_live"]:
# self.current_game["bases_occupied"] = [
# random.choice([True, False]) for _ in range(3)
# ]
# self.current_game["balls"] = random.choice([1, 2, 3])
# self.current_game["strikes"] = random.choice([1, 2])
# self.current_game["outs"] = random.choice([1, 2])
if self.current_game["inning_half"] == "top": self.current_game["inning_half"] = "bottom"
else: self.current_game["inning_half"] = "top"; self.current_game["inning"] += 1
self.current_game["balls"] = (self.current_game["balls"] + 1) % 4
self.current_game["strikes"] = (self.current_game["strikes"] + 1) % 3
self.current_game["outs"] = (self.current_game["outs"] + 1) % 3
self.current_game["bases_occupied"] = [not b for b in self.current_game["bases_occupied"]]
if self.current_game["inning"] % 2 == 0: self.current_game["home_score"] = str(int(self.current_game["home_score"]) + 1)
else: self.current_game["away_score"] = str(int(self.current_game["away_score"]) + 1)
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
"""Draw the detailed scorebug layout for a live NCAA FB game.""" # Updated docstring
try: try:
# Only show live games main_img = Image.new(
if not self._is_baseball_game_live(game): "RGBA", (self.display_width, self.display_height), (0, 0, 0, 255)
return False )
overlay = Image.new(
"RGBA", (self.display_width, self.display_height), (0, 0, 0, 0)
)
draw_overlay = ImageDraw.Draw(
overlay
) # Draw text elements on overlay first
# Check if game meets display criteria home_logo = self._load_and_resize_logo(
return self._should_show_game(game) game["home_id"],
game["home_abbr"],
game["home_logo_path"],
game.get("home_logo_url"),
)
away_logo = self._load_and_resize_logo(
game["away_id"],
game["away_abbr"],
game["away_logo_path"],
game.get("away_logo_url"),
)
if not home_logo or not away_logo:
self.logger.error(
f"Failed to load logos for live game: {game.get('id')}"
) # Changed log prefix
# Draw placeholder text if logos fail
draw_final = ImageDraw.Draw(main_img.convert("RGB"))
self._draw_text_with_outline(
draw_final, "Logo Error", (5, 5), self.fonts["status"]
)
self.display_manager.image.paste(main_img.convert("RGB"), (0, 0))
self.display_manager.update_display()
return
center_y = self.display_height // 2
# Draw logos (shifted slightly more inward than NHL perhaps)
home_x = (
self.display_width - home_logo.width + 10
) # adjusted from 18 # Adjust position as needed
home_y = center_y - (home_logo.height // 2)
main_img.paste(home_logo, (home_x, home_y), home_logo)
away_x = -10 # adjusted from 18 # Adjust position as needed
away_y = center_y - (away_logo.height // 2)
main_img.paste(away_logo, (away_x, away_y), away_logo)
# --- Live Game Specific Elements ---
# Define default text color
text_color = (255, 255, 255)
# Draw Inning (Top Center)
inning_half = game["inning_half"]
inning_num = game["inning"]
if game["is_final"]:
inning_text = "FINAL"
else:
inning_half_indicator = (
"" if game["inning_half"].lower() == "top" else ""
)
inning_num = game["inning"]
inning_text = f"{inning_half_indicator}{inning_num}"
inning_bbox = draw_overlay.textbbox(
(0, 0), inning_text, font=self.display_manager.font
)
inning_width = inning_bbox[2] - inning_bbox[0]
inning_x = (self.display_width - inning_width) // 2
inning_y = 1 # Position near top center
# draw_overlay.text((inning_x, inning_y), inning_text, fill=(255, 255, 255), font=self.display_manager.font)
self._draw_text_with_outline(
draw_overlay,
inning_text,
(inning_x, inning_y),
self.display_manager.font,
)
# --- REVISED BASES AND OUTS DRAWING ---
bases_occupied = game["bases_occupied"] # [1st, 2nd, 3rd]
outs = game.get("outs", 0)
inning_half = game["inning_half"]
# Define geometry
base_diamond_size = 7
out_circle_diameter = 3
out_vertical_spacing = 2 # Space between out circles
spacing_between_bases_outs = (
3 # Horizontal space between base cluster and out column
)
base_vert_spacing = 1 # Internal vertical space in base cluster
base_horiz_spacing = 1 # Internal horizontal space in base cluster
# Calculate cluster dimensions
base_cluster_height = (
base_diamond_size + base_vert_spacing + base_diamond_size
)
base_cluster_width = (
base_diamond_size + base_horiz_spacing + base_diamond_size
)
out_cluster_height = 3 * out_circle_diameter + 2 * out_vertical_spacing
out_cluster_width = out_circle_diameter
# Calculate overall start positions
overall_start_y = (
inning_bbox[3] + 0
) # Start immediately below inning text (moved up 3 pixels)
# Center the BASE cluster horizontally
bases_origin_x = (self.display_width - base_cluster_width) // 2
# Determine relative positions for outs based on inning half
if inning_half == "top": # Away batting, outs on left
outs_column_x = (
bases_origin_x - spacing_between_bases_outs - out_cluster_width
)
else: # Home batting, outs on right
outs_column_x = (
bases_origin_x + base_cluster_width + spacing_between_bases_outs
)
# Calculate vertical alignment offset for outs column (center align with bases cluster)
outs_column_start_y = (
overall_start_y + (base_cluster_height // 2) - (out_cluster_height // 2)
)
# --- Draw Bases (Diamonds) ---
base_color_occupied = (255, 255, 255)
base_color_empty = (255, 255, 255) # Outline color
h_d = base_diamond_size // 2
# 2nd Base (Top center relative to bases_origin_x)
c2x = bases_origin_x + base_cluster_width // 2
c2y = overall_start_y + h_d
poly2 = [
(c2x, overall_start_y),
(c2x + h_d, c2y),
(c2x, c2y + h_d),
(c2x - h_d, c2y),
]
if bases_occupied[1]:
draw_overlay.polygon(poly2, fill=base_color_occupied)
else:
draw_overlay.polygon(poly2, outline=base_color_empty)
base_bottom_y = c2y + h_d # Bottom Y of 2nd base diamond
# 3rd Base (Bottom left relative to bases_origin_x)
c3x = bases_origin_x + h_d
c3y = base_bottom_y + base_vert_spacing + h_d
poly3 = [
(c3x, base_bottom_y + base_vert_spacing),
(c3x + h_d, c3y),
(c3x, c3y + h_d),
(c3x - h_d, c3y),
]
if bases_occupied[2]:
draw_overlay.polygon(poly3, fill=base_color_occupied)
else:
draw_overlay.polygon(poly3, outline=base_color_empty)
# 1st Base (Bottom right relative to bases_origin_x)
c1x = bases_origin_x + base_cluster_width - h_d
c1y = base_bottom_y + base_vert_spacing + h_d
poly1 = [
(c1x, base_bottom_y + base_vert_spacing),
(c1x + h_d, c1y),
(c1x, c1y + h_d),
(c1x - h_d, c1y),
]
if bases_occupied[0]:
draw_overlay.polygon(poly1, fill=base_color_occupied)
else:
draw_overlay.polygon(poly1, outline=base_color_empty)
# --- Draw Outs (Vertical Circles) ---
circle_color_out = (255, 255, 255)
circle_color_empty_outline = (100, 100, 100)
for i in range(3):
cx = outs_column_x
cy = outs_column_start_y + i * (
out_circle_diameter + out_vertical_spacing
)
coords = [cx, cy, cx + out_circle_diameter, cy + out_circle_diameter]
if i < outs:
draw_overlay.ellipse(coords, fill=circle_color_out)
else:
draw_overlay.ellipse(coords, outline=circle_color_empty_outline)
# --- Draw Balls-Strikes Count (BDF Font) ---
balls = game.get("balls", 0)
strikes = game.get("strikes", 0)
# Add debug logging for count with cooldown
current_time = time.time()
if (
game["home_abbr"] in self.favorite_teams
or game["away_abbr"] in self.favorite_teams
) and current_time - self.last_count_log_time >= self.count_log_interval:
self.logger.debug(f"Displaying count: {balls}-{strikes}")
self.logger.debug(
f"Raw count data: balls={game.get('balls')}, strikes={game.get('strikes')}"
)
self.last_count_log_time = current_time
count_text = f"{balls}-{strikes}"
bdf_font = self.display_manager.calendar_font
bdf_font.set_char_size(height=7 * 64) # Set 7px height
count_text_width = self.display_manager.get_text_width(count_text, bdf_font)
# Position below the base/out cluster
cluster_bottom_y = (
overall_start_y + base_cluster_height
) # Find the bottom of the taller part (bases)
count_y = cluster_bottom_y + 2 # Start 2 pixels below cluster
# Center horizontally within the BASE cluster width
count_x = bases_origin_x + (base_cluster_width - count_text_width) // 2
# Ensure draw object is set and draw text
self.display_manager.draw = draw_overlay
# self.display_manager._draw_bdf_text(count_text, count_x, count_y, text_color, font=bdf_font)
# Use _draw_text_with_outline for count text
# self._draw_text_with_outline(draw, count_text, (count_x, count_y), bdf_font, fill=text_color)
# Draw Balls-Strikes Count with outline using BDF font
# Define outline color (consistent with _draw_text_with_outline default)
outline_color_for_bdf = (0, 0, 0)
# Draw outline
for dx_offset, dy_offset in [
(-1, -1),
(-1, 0),
(-1, 1),
(0, -1),
(0, 1),
(1, -1),
(1, 0),
(1, 1),
]:
self.display_manager._draw_bdf_text(
count_text,
count_x + dx_offset,
count_y + dy_offset,
color=outline_color_for_bdf,
font=bdf_font,
)
# Draw main text
self.display_manager._draw_bdf_text(
count_text, count_x, count_y, color=text_color, font=bdf_font
)
# Draw Team:Score at the bottom (matching main branch format)
score_font = self.display_manager.font # Use PressStart2P
outline_color = (0, 0, 0)
score_text_color = (
255,
255,
255,
) # Use a specific name for score text color
# Helper function for outlined text
def draw_bottom_outlined_text(x, y, text):
self._draw_text_with_outline(
draw_overlay,
text,
(x, y),
score_font,
fill=score_text_color,
outline_color=outline_color,
)
away_abbr = game["away_abbr"]
home_abbr = game["home_abbr"]
away_score_str = str(game["away_score"])
home_score_str = str(game["home_score"])
away_text = f"{away_abbr}:{away_score_str}"
home_text = f"{home_abbr}:{home_score_str}"
# Calculate Y position (bottom edge)
# Get font height (approximate or precise)
try:
font_height = score_font.getbbox("A")[3] - score_font.getbbox("A")[1]
except AttributeError:
font_height = 8 # Fallback for default font
score_y = (
self.display_height - font_height - 2
) # 2 pixels padding from bottom
# Away Team:Score (Bottom Left)
away_score_x = 2 # 2 pixels padding from left
draw_bottom_outlined_text(away_score_x, score_y, away_text)
# Home Team:Score (Bottom Right)
home_text_bbox = draw_overlay.textbbox((0, 0), home_text, font=score_font)
home_text_width = home_text_bbox[2] - home_text_bbox[0]
home_score_x = (
self.display_width - home_text_width - 2
) # 2 pixels padding from right
draw_bottom_outlined_text(home_score_x, score_y, home_text)
# Draw gambling odds if available
if "odds" in game and game["odds"]:
self._draw_dynamic_odds(
draw_overlay, game["odds"], self.display_width, self.display_height
)
# Composite the text overlay onto the main image
main_img = Image.alpha_composite(main_img, overlay)
main_img = main_img.convert("RGB") # Convert for display
# Display the final image
self.display_manager.image.paste(main_img, (0, 0))
self.display_manager.update_display() # Update display here for live
except Exception as e: except Exception as e:
self.logger.error(f"Error checking if baseball game should be shown: {e}") self.logger.error(
return False f"Error displaying live Football game: {e}", exc_info=True
) # Changed log prefix

View File

@@ -5,20 +5,14 @@ from datetime import datetime, timezone, timedelta
import logging import logging
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
import time import time
import pytz
from src.base_classes.sports import SportsCore
from src.base_classes.api_extractors import ESPNFootballExtractor
from src.base_classes.data_sources import ESPNDataSource from src.base_classes.data_sources import ESPNDataSource
import requests from src.base_classes.sports import SportsCore, SportsLive
class Football(SportsCore): class Football(SportsCore):
"""Base class for football sports with common functionality.""" """Base class for football sports with common functionality."""
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
super().__init__(config, display_manager, cache_manager, logger, sport_key) super().__init__(config, display_manager, cache_manager, logger, sport_key)
# Initialize football-specific architecture components
self.api_extractor = ESPNFootballExtractor(logger)
self.data_source = ESPNDataSource(logger) self.data_source = ESPNDataSource(logger)
self.sport = "football" self.sport = "football"
@@ -82,12 +76,12 @@ class Football(SportsCore):
period = status.get("period", 0) period = status.get("period", 0)
period_text = "" period_text = ""
if status["type"]["state"] == "in": if status["type"]["state"] == "in":
if period == 0: period_text = "Start" # Before kickoff if period == 0:
elif period == 1: period_text = "Q1" period_text = "Start" # Before kickoff
elif period == 2: period_text = "Q2" elif period >= 1 and period <= 4:
elif period == 3: period_text = "Q3" # Fixed: period 3 is 3rd quarter, not halftime period_text = f"Q{period}" # OT starts after Q4
elif period == 4: period_text = "Q4" elif period > 4:
elif period > 4: period_text = "OT" # OT starts after Q4 period_text = f"OT{period - 4}" # OT starts after Q4
elif status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME": # Check explicit halftime state elif status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME": # Check explicit halftime state
period_text = "HALF" period_text = "HALF"
elif status["type"]["state"] == "post": elif status["type"]["state"] == "post":
@@ -122,49 +116,11 @@ class Football(SportsCore):
logging.error(f"Error extracting game details: {e} from event: {game_event.get('id')}", exc_info=True) logging.error(f"Error extracting game details: {e} from event: {game_event.get('id')}", exc_info=True)
return None return None
class FootballLive(Football): class FootballLive(Football, SportsLive):
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
super().__init__(config, display_manager, cache_manager, logger, sport_key) super().__init__(config, display_manager, cache_manager, logger, sport_key)
self.update_interval = self.mode_config.get("live_update_interval", 15)
self.no_data_interval = 300
self.last_update = 0
self.live_games = []
self.current_game_index = 0
self.last_game_switch = 0
self.game_display_duration = self.mode_config.get("live_game_duration", 20)
self.last_display_update = 0
self.last_log_time = 0
self.log_interval = 300
def update(self): def _test_mode_update(self):
"""Update live game data and handle game switching."""
if not self.is_enabled:
return
# Define current_time and interval before the problematic line (originally line 455)
# Ensure 'import time' is present at the top of the file.
current_time = time.time()
# Define interval using a pattern similar to NFLLiveManager's update method.
# Uses getattr for robustness, assuming attributes for live_games, test_mode,
# no_data_interval, and update_interval are available on self.
_live_games_attr = getattr(self, 'live_games', [])
_test_mode_attr = getattr(self, 'test_mode', False) # test_mode is often from a base class or config
_no_data_interval_attr = getattr(self, 'no_data_interval', 300) # Default similar to NFLLiveManager
_update_interval_attr = getattr(self, 'update_interval', 15) # Default similar to NFLLiveManager
interval = _no_data_interval_attr if not _live_games_attr and not _test_mode_attr else _update_interval_attr
# Original line from traceback (line 455), now with variables defined:
if current_time - self.last_update >= interval:
self.last_update = current_time
# Fetch rankings if enabled
if self.show_ranking:
self._fetch_team_rankings()
if self.test_mode:
# Simulate clock running down in test mode
if self.current_game and self.current_game["is_live"]: if self.current_game and self.current_game["is_live"]:
try: try:
minutes, seconds = map(int, self.current_game["clock"].split(':')) minutes, seconds = map(int, self.current_game["clock"].split(':'))
@@ -201,100 +157,7 @@ class FootballLive(Football):
except ValueError: except ValueError:
self.logger.warning("Test mode: Could not parse clock") # Changed log prefix self.logger.warning("Test mode: Could not parse clock") # Changed log prefix
# No actual display call here, let main loop handle it # No actual display call here, let main loop handle it
else:
# Fetch live game data
data = self._fetch_data()
new_live_games = []
if data and "events" in data:
for game in data["events"]:
details = self._extract_game_details(game)
if details and (details["is_live"] or details["is_halftime"]):
# If show_favorite_teams_only is true, only add if it's a favorite.
# Otherwise, add all games.
if self.show_favorite_teams_only:
if details["home_abbr"] in self.favorite_teams or details["away_abbr"] in self.favorite_teams:
new_live_games.append(details)
else:
new_live_games.append(details)
for game in new_live_games:
if self.show_odds:
self._fetch_odds(game)
# Log changes or periodically
current_time_for_log = time.time() # Use a consistent time for logging comparison
should_log = (
current_time_for_log - self.last_log_time >= self.log_interval or
len(new_live_games) != len(self.live_games) or
any(g1['id'] != g2.get('id') for g1, g2 in zip(self.live_games, new_live_games)) or # Check if game IDs changed
(not self.live_games and new_live_games) # Log if games appeared
)
if should_log:
if new_live_games:
filter_text = "favorite teams" if self.show_favorite_teams_only else "all teams"
self.logger.info(f"Found {len(new_live_games)} live/halftime games for {filter_text}.")
for game_info in new_live_games: # Renamed game to game_info
self.logger.info(f" - {game_info['away_abbr']}@{game_info['home_abbr']} ({game_info.get('status_text', 'N/A')})")
else:
filter_text = "favorite teams" if self.show_favorite_teams_only else "criteria"
self.logger.info(f"No live/halftime games found for {filter_text}.")
self.last_log_time = current_time_for_log
# Update game list and current game
if new_live_games:
# Check if the games themselves changed, not just scores/time
new_game_ids = {g['id'] for g in new_live_games}
current_game_ids = {g['id'] for g in self.live_games}
if new_game_ids != current_game_ids:
self.live_games = sorted(new_live_games, key=lambda g: g.get('start_time_utc') or datetime.now(timezone.utc)) # Sort by start time
# Reset index if current game is gone or list is new
if not self.current_game or self.current_game['id'] not in new_game_ids:
self.current_game_index = 0
self.current_game = self.live_games[0] if self.live_games else None
self.last_game_switch = current_time
else:
# Find current game's new index if it still exists
try:
self.current_game_index = next(i for i, g in enumerate(self.live_games) if g['id'] == self.current_game['id'])
self.current_game = self.live_games[self.current_game_index] # Update current_game with fresh data
except StopIteration: # Should not happen if check above passed, but safety first
self.current_game_index = 0
self.current_game = self.live_games[0]
self.last_game_switch = current_time
else:
# Just update the data for the existing games
temp_game_dict = {g['id']: g for g in new_live_games}
self.live_games = [temp_game_dict.get(g['id'], g) for g in self.live_games] # Update in place
if self.current_game:
self.current_game = temp_game_dict.get(self.current_game['id'], self.current_game)
# Display update handled by main loop based on interval
else:
# No live games found
if self.live_games: # Were there games before?
self.logger.info("Live games previously showing have ended or are no longer live.") # Changed log prefix
self.live_games = []
self.current_game = None
self.current_game_index = 0
else:
# Error fetching data or no events
if self.live_games: # Were there games before?
self.logger.warning("Could not fetch update; keeping existing live game data for now.") # Changed log prefix
else:
self.logger.warning("Could not fetch data and no existing live games.") # Changed log prefix
self.current_game = None # Clear current game if fetch fails and no games were active
# Handle game switching (outside test mode check)
if not self.test_mode and len(self.live_games) > 1 and (current_time - self.last_game_switch) >= self.game_display_duration:
self.current_game_index = (self.current_game_index + 1) % len(self.live_games)
self.current_game = self.live_games[self.current_game_index]
self.last_game_switch = current_time
self.logger.info(f"Switched live view to: {self.current_game['away_abbr']}@{self.current_game['home_abbr']}") # Changed log prefix
# Force display update via flag or direct call if needed, but usually let main loop handle
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
"""Draw the detailed scorebug layout for a live NCAA FB game.""" # Updated docstring """Draw the detailed scorebug layout for a live NCAA FB game.""" # Updated docstring

View File

@@ -1,30 +1,37 @@
from typing import Dict, Any, Optional
from src.display_manager import DisplayManager
from src.cache_manager import CacheManager
from datetime import datetime, timezone
import logging import logging
from PIL import Image, ImageDraw, ImageFont
import time import time
from src.base_classes.sports import SportsCore from datetime import datetime, timezone
from src.base_classes.api_extractors import ESPNHockeyExtractor from typing import Any, Dict, Optional
from PIL import Image, ImageDraw, ImageFont
from src.base_classes.data_sources import ESPNDataSource from src.base_classes.data_sources import ESPNDataSource
from src.base_classes.sports import SportsCore, SportsLive
from src.cache_manager import CacheManager
from src.display_manager import DisplayManager
class Hockey(SportsCore): class Hockey(SportsCore):
"""Base class for hockey sports with common functionality.""" """Base class for hockey sports with common functionality."""
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): def __init__(
self,
config: Dict[str, Any],
display_manager: DisplayManager,
cache_manager: CacheManager,
logger: logging.Logger,
sport_key: str,
):
super().__init__(config, display_manager, cache_manager, logger, sport_key) super().__init__(config, display_manager, cache_manager, logger, sport_key)
# Initialize hockey-specific architecture components
self.api_extractor = ESPNHockeyExtractor(logger)
self.data_source = ESPNDataSource(logger) self.data_source = ESPNDataSource(logger)
self.sport = "hockey" self.sport = "hockey"
def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: def _extract_game_details(self, game_event: Dict) -> Optional[Dict]:
"""Extract relevant game details from ESPN NCAA FB API response.""" """Extract relevant game details from ESPN NCAA FB API response."""
# --- THIS METHOD MAY NEED ADJUSTMENTS FOR NCAA FB API DIFFERENCES --- # --- THIS METHOD MAY NEED ADJUSTMENTS FOR NCAA FB API DIFFERENCES ---
details, home_team, away_team, status, situation = self._extract_game_details_common(game_event) details, home_team, away_team, status, situation = (
self._extract_game_details_common(game_event)
)
if details is None or home_team is None or away_team is None or status is None: if details is None or home_team is None or away_team is None or status is None:
return return
try: try:
@@ -32,7 +39,46 @@ class Hockey(SportsCore):
status = competition["status"] status = competition["status"]
powerplay = False powerplay = False
penalties = "" penalties = ""
shots_on_goal = {"home": 0, "away": 0} home_team_saves = next(
(
int(c["displayValue"])
for c in home_team["statistics"]
if c.get("name") == "saves"
),
0,
)
home_team_saves_per = next(
(
float(c["displayValue"])
for c in home_team["statistics"]
if c.get("name") == "savePct"
),
0.0,
)
away_team_saves = next(
(
int(c["displayValue"])
for c in away_team["statistics"]
if c.get("name") == "saves"
),
0,
)
away_team_saves_per = next(
(
float(c["displayValue"])
for c in away_team["statistics"]
if c.get("name") == "savePct"
),
0.0,
)
home_shots = 0
away_shots = 0
if home_team_saves_per > 0:
away_shots = round(home_team_saves / home_team_saves_per)
if away_team_saves_per > 0:
home_shots = round(away_team_saves / away_team_saves_per)
if situation and status["type"]["state"] == "in": if situation and status["type"]["state"] == "in":
# Detect scoring events from status detail # Detect scoring events from status detail
@@ -40,79 +86,72 @@ class Hockey(SportsCore):
status_short = status["type"].get("shortDetail", "").lower() status_short = status["type"].get("shortDetail", "").lower()
powerplay = situation.get("isPowerPlay", False) powerplay = situation.get("isPowerPlay", False)
penalties = situation.get("penalties", "") penalties = situation.get("penalties", "")
shots_on_goal = {
"home": situation.get("homeShots", 0),
"away": situation.get("awayShots", 0)
}
# Format period/quarter # Format period/quarter
period = status.get("period", 0) period = status.get("period", 0)
period_text = "" period_text = ""
if status["type"]["state"] == "in": if status["type"]["state"] == "in":
if period == 0: period_text = "Start" # Before kickoff if period == 0:
elif period == 1: period_text = "P1" period_text = "Start" # Before kickoff
elif period == 2: period_text = "P2" elif period >= 1 and period <= 3:
elif period == 3: period_text = "P3" # Fixed: period 3 is 3rd quarter, not halftime period_text = f"P{period}" # OT starts after Q4
elif period > 3: period_text = f"OT {period - 3}" # OT starts after P3 elif period > 3:
period_text = f"OT{period - 3}" # OT starts after Q4
elif status["type"]["state"] == "post": elif status["type"]["state"] == "post":
if period > 3 : if period > 3:
period_text = "Final/OT" period_text = "Final/OT"
else: else:
period_text = "Final" period_text = "Final"
elif status["type"]["state"] == "pre": elif status["type"]["state"] == "pre":
period_text = details.get("game_time", "") # Show time for upcoming period_text = details.get("game_time", "") # Show time for upcoming
details.update({ details.update(
{
"period": period, "period": period,
"period_text": period_text, # Formatted quarter/status "period_text": period_text, # Formatted quarter/status
"clock": status.get("displayClock", "0:00"), "clock": status.get("displayClock", "0:00"),
"power_play": powerplay, "power_play": powerplay,
"penalties": penalties, "penalties": penalties,
"shots_on_goal": shots_on_goal "home_shots": home_shots,
}) "away_shots": away_shots,
}
)
# Basic validation (can be expanded) # Basic validation (can be expanded)
if not details['home_abbr'] or not details['away_abbr']: if not details["home_abbr"] or not details["away_abbr"]:
self.logger.warning(f"Missing team abbreviation in event: {details['id']}") self.logger.warning(
f"Missing team abbreviation in event: {details['id']}"
)
return None return None
self.logger.debug(f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}") self.logger.debug(
f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}"
)
return details return details
except Exception as e: except Exception as e:
# Log the problematic event structure if possible # Log the problematic event structure if possible
logging.error(f"Error extracting game details: {e} from event: {game_event.get('id')}", exc_info=True) self.logger.error(
f"Error extracting game details: {e} from event: {game_event.get('id')}",
exc_info=True,
)
return None return None
class HockeyLive(Hockey):
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): class HockeyLive(Hockey, SportsLive):
def __init__(
self,
config: Dict[str, Any],
display_manager: DisplayManager,
cache_manager: CacheManager,
logger: logging.Logger,
sport_key: str,
):
super().__init__(config, display_manager, cache_manager, logger, sport_key) super().__init__(config, display_manager, cache_manager, logger, sport_key)
self.update_interval = self.mode_config.get("live_update_interval", 15)
self.no_data_interval = 300
self.last_update = 0
self.live_games = []
self.current_game_index = 0
self.last_game_switch = 0
self.game_display_duration = self.mode_config.get("live_game_duration", 20)
self.last_display_update = 0
self.last_log_time = 0
self.log_interval = 300
def update(self): def _test_mode_update(self):
"""Update live game data.""" if self.current_game and self.current_game["is_live"]:
if not self.is_enabled: return
current_time = time.time()
interval = self.no_data_interval if not self.live_games else self.update_interval
if current_time - self.last_update >= interval:
self.last_update = current_time
if self.show_ranking:
self._fetch_team_rankings()
if self.test_mode:
# For testing, we'll just update the clock to show it's working # For testing, we'll just update the clock to show it's working
if self.current_game:
minutes = int(self.current_game["clock"].split(":")[0]) minutes = int(self.current_game["clock"].split(":")[0])
seconds = int(self.current_game["clock"].split(":")[1]) seconds = int(self.current_game["clock"].split(":")[1])
seconds -= 1 seconds -= 1
@@ -127,135 +166,112 @@ class HockeyLive(Hockey):
self.current_game["period"] = 1 self.current_game["period"] = 1
self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}" self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}"
# Always update display in test mode # Always update display in test mode
self.display(force_clear=True)
else:
# Fetch live game data from ESPN API
data = self._fetch_data()
if data and "events" in data:
# Find all live games involving favorite teams
new_live_games = []
for event in data["events"]:
details = self._extract_game_details(event)
if details and details["is_live"]:
self._fetch_odds(details)
new_live_games.append(details)
# Filter for favorite teams only if the config is set
if self.show_favorite_teams_only:
new_live_games = [game for game in new_live_games
if game['home_abbr'] in self.favorite_teams or
game['away_abbr'] in self.favorite_teams]
# Only log if there's a change in games or enough time has passed
should_log = (
current_time - self.last_log_time >= self.log_interval or
len(new_live_games) != len(self.live_games) or
not self.live_games # Log if we had no games before
)
if should_log:
if new_live_games:
filter_text = "favorite teams" if self.show_favorite_teams_only else "all teams"
self.logger.info(f"[NCAAMH] Found {len(new_live_games)} live games involving {filter_text}")
for game in new_live_games:
self.logger.info(f"[NCAAMH] Live game: {game['away_abbr']} vs {game['home_abbr']} - Period {game['period']}, {game['clock']}")
else:
filter_text = "favorite teams" if self.show_favorite_teams_only else "criteria"
self.logger.info(f"[NCAAMH] No live games found matching {filter_text}")
self.last_log_time = current_time
if new_live_games:
# Update the current game with the latest data
for new_game in new_live_games:
if self.current_game and (
(new_game["home_abbr"] == self.current_game["home_abbr"] and
new_game["away_abbr"] == self.current_game["away_abbr"]) or
(new_game["home_abbr"] == self.current_game["away_abbr"] and
new_game["away_abbr"] == self.current_game["home_abbr"])
):
self.current_game = new_game
break
# Only update the games list if we have new games
if not self.live_games or set(game["away_abbr"] + game["home_abbr"] for game in new_live_games) != set(game["away_abbr"] + game["home_abbr"] for game in self.live_games):
self.live_games = new_live_games
# If we don't have a current game or it's not in the new list, start from the beginning
if not self.current_game or self.current_game not in self.live_games:
self.current_game_index = 0
self.current_game = self.live_games[0]
self.last_game_switch = current_time
# Update display if data changed, limit rate
if current_time - self.last_display_update >= 1.0:
# self.display(force_clear=True) # REMOVED: DisplayController handles this
self.last_display_update = current_time
else:
# No live games found
self.live_games = []
self.current_game = None
# Check if it's time to switch games
if len(self.live_games) > 1 and (current_time - self.last_game_switch) >= self.game_display_duration:
self.current_game_index = (self.current_game_index + 1) % len(self.live_games)
self.current_game = self.live_games[self.current_game_index]
self.last_game_switch = current_time
# self.display(force_clear=True) # REMOVED: DisplayController handles this
self.last_display_update = current_time # Track time for potential display update
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
"""Draw the detailed scorebug layout for a live NCAA FB game.""" # Updated docstring """Draw the detailed scorebug layout for a live NCAA FB game.""" # Updated docstring
try: try:
main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) main_img = Image.new(
overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) "RGBA", (self.display_width, self.display_height), (0, 0, 0, 255)
draw_overlay = ImageDraw.Draw(overlay) # Draw text elements on overlay first )
home_logo = self._load_and_resize_logo(game["home_id"], game["home_abbr"], game["home_logo_path"], game.get("home_logo_url")) overlay = Image.new(
away_logo = self._load_and_resize_logo(game["away_id"], game["away_abbr"], game["away_logo_path"], game.get("away_logo_url")) "RGBA", (self.display_width, self.display_height), (0, 0, 0, 0)
)
draw_overlay = ImageDraw.Draw(
overlay
) # Draw text elements on overlay first
home_logo = self._load_and_resize_logo(
game["home_id"],
game["home_abbr"],
game["home_logo_path"],
game.get("home_logo_url"),
)
away_logo = self._load_and_resize_logo(
game["away_id"],
game["away_abbr"],
game["away_logo_path"],
game.get("away_logo_url"),
)
if not home_logo or not away_logo: if not home_logo or not away_logo:
self.logger.error(f"Failed to load logos for live game: {game.get('id')}") # Changed log prefix self.logger.error(
f"Failed to load logos for live game: {game.get('id')}"
) # Changed log prefix
# Draw placeholder text if logos fail # Draw placeholder text if logos fail
draw_final = ImageDraw.Draw(main_img.convert('RGB')) draw_final = ImageDraw.Draw(main_img.convert("RGB"))
self._draw_text_with_outline(draw_final, "Logo Error", (5,5), self.fonts['status']) self._draw_text_with_outline(
self.display_manager.image.paste(main_img.convert('RGB'), (0, 0)) draw_final, "Logo Error", (5, 5), self.fonts["status"]
)
self.display_manager.image.paste(main_img.convert("RGB"), (0, 0))
self.display_manager.update_display() self.display_manager.update_display()
return return
center_y = self.display_height // 2 center_y = self.display_height // 2
# Draw logos (shifted slightly more inward than NHL perhaps) # Draw logos (shifted slightly more inward than NHL perhaps)
home_x = self.display_width - home_logo.width + 10 #adjusted from 18 # Adjust position as needed home_x = (
self.display_width - home_logo.width + 10
) # adjusted from 18 # Adjust position as needed
home_y = center_y - (home_logo.height // 2) home_y = center_y - (home_logo.height // 2)
main_img.paste(home_logo, (home_x, home_y), home_logo) main_img.paste(home_logo, (home_x, home_y), home_logo)
away_x = -10 #adjusted from 18 # Adjust position as needed away_x = -10 # adjusted from 18 # Adjust position as needed
away_y = center_y - (away_logo.height // 2) away_y = center_y - (away_logo.height // 2)
main_img.paste(away_logo, (away_x, away_y), away_logo) main_img.paste(away_logo, (away_x, away_y), away_logo)
# --- Draw Text Elements on Overlay --- # --- Draw Text Elements on Overlay ---
# Note: Rankings are now handled in the records/rankings section below # Note: Rankings are now handled in the records/rankings section below
# Period/Quarter and Clock (Top center)
period_clock_text = (
f"{game.get('period_text', '')} {game.get('clock', '')}".strip()
)
if game.get("is_halftime"):
period_clock_text = "Halftime" # Override for halftime
status_width = draw_overlay.textlength(
period_clock_text, font=self.fonts["time"]
)
status_x = (self.display_width - status_width) // 2
status_y = 1 # Position at top
self._draw_text_with_outline(
draw_overlay,
period_clock_text,
(status_x, status_y),
self.fonts["time"],
)
# Scores (centered, slightly above bottom) # Scores (centered, slightly above bottom)
home_score = str(game.get("home_score", "0")) home_score = str(game.get("home_score", "0"))
away_score = str(game.get("away_score", "0")) away_score = str(game.get("away_score", "0"))
score_text = f"{away_score}-{home_score}" score_text = f"{away_score}-{home_score}"
score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) score_width = draw_overlay.textlength(score_text, font=self.fonts["score"])
score_x = (self.display_width - score_width) // 2 score_x = (self.display_width - score_width) // 2
score_y = (self.display_height // 2) - 3 #centered #from 14 # Position score higher score_y = (
self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score']) self.display_height // 2
) - 3 # centered #from 14 # Position score higher
self._draw_text_with_outline(
draw_overlay, score_text, (score_x, score_y), self.fonts["score"]
)
# Period/Quarter and Clock (Top center) # Shots on Goal
period_clock_text = f"{game.get('period_text', '')} {game.get('clock', '')}".strip() shots_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6)
if game.get("is_halftime"): period_clock_text = "Halftime" # Override for halftime home_shots = str(game.get("home_shots", "0"))
away_shots = str(game.get("away_shots", "0"))
status_width = draw_overlay.textlength(period_clock_text, font=self.fonts['time']) shots_text = f"{away_shots} SHOTS {home_shots}"
status_x = (self.display_width - status_width) // 2 shots_width = draw_overlay.textlength(shots_text, font=shots_font)
status_y = 1 # Position at top shots_x = (self.display_width - shots_width) // 2
self._draw_text_with_outline(draw_overlay, period_clock_text, (status_x, status_y), self.fonts['time']) shots_y = (
self.display_height - 10
) # centered #from 14 # Position score higher
self._draw_text_with_outline(
draw_overlay, shots_text, (shots_x, shots_y), shots_font
)
# Draw odds if available # Draw odds if available
if 'odds' in game and game['odds']: if "odds" in game and game["odds"]:
self._draw_dynamic_odds(draw_overlay, game['odds'], self.display_width, self.display_height) self._draw_dynamic_odds(
draw_overlay, game["odds"], self.display_width, self.display_height
)
# Draw records or rankings if enabled # Draw records or rankings if enabled
if self.show_records or self.show_ranking: if self.show_records or self.show_ranking:
@@ -264,16 +280,20 @@ class HockeyLive(Hockey):
self.logger.debug(f"Loaded 6px record font successfully") self.logger.debug(f"Loaded 6px record font successfully")
except IOError: except IOError:
record_font = ImageFont.load_default() record_font = ImageFont.load_default()
self.logger.warning(f"Failed to load 6px font, using default font (size: {record_font.size})") self.logger.warning(
f"Failed to load 6px font, using default font (size: {record_font.size})"
)
# Get team abbreviations # Get team abbreviations
away_abbr = game.get('away_abbr', '') away_abbr = game.get("away_abbr", "")
home_abbr = game.get('home_abbr', '') home_abbr = game.get("home_abbr", "")
record_bbox = draw_overlay.textbbox((0,0), "0-0", font=record_font) record_bbox = draw_overlay.textbbox((0, 0), "0-0", font=record_font)
record_height = record_bbox[3] - record_bbox[1] record_height = record_bbox[3] - record_bbox[1]
record_y = self.display_height - record_height - 4 record_y = self.display_height - record_height - 4
self.logger.debug(f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}") self.logger.debug(
f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}"
)
# Display away team info # Display away team info
if away_abbr: if away_abbr:
@@ -284,24 +304,31 @@ class HockeyLive(Hockey):
away_text = f"#{away_rank}" away_text = f"#{away_rank}"
else: else:
# Show nothing for unranked teams when rankings are prioritized # Show nothing for unranked teams when rankings are prioritized
away_text = '' away_text = ""
elif self.show_ranking: elif self.show_ranking:
# Show ranking only if available # Show ranking only if available
away_rank = self._team_rankings_cache.get(away_abbr, 0) away_rank = self._team_rankings_cache.get(away_abbr, 0)
if away_rank > 0: if away_rank > 0:
away_text = f"#{away_rank}" away_text = f"#{away_rank}"
else: else:
away_text = '' away_text = ""
elif self.show_records: elif self.show_records:
# Show record only when rankings are disabled # Show record only when rankings are disabled
away_text = game.get('away_record', '') away_text = game.get("away_record", "")
else: else:
away_text = '' away_text = ""
if away_text: if away_text:
away_record_x = 3 away_record_x = 3
self.logger.debug(f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}") self.logger.debug(
self._draw_text_with_outline(draw_overlay, away_text, (away_record_x, record_y), record_font) f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}"
)
self._draw_text_with_outline(
draw_overlay,
away_text,
(away_record_x, record_y),
record_font,
)
# Display home team info # Display home team info
if home_abbr: if home_abbr:
@@ -312,34 +339,45 @@ class HockeyLive(Hockey):
home_text = f"#{home_rank}" home_text = f"#{home_rank}"
else: else:
# Show nothing for unranked teams when rankings are prioritized # Show nothing for unranked teams when rankings are prioritized
home_text = '' home_text = ""
elif self.show_ranking: elif self.show_ranking:
# Show ranking only if available # Show ranking only if available
home_rank = self._team_rankings_cache.get(home_abbr, 0) home_rank = self._team_rankings_cache.get(home_abbr, 0)
if home_rank > 0: if home_rank > 0:
home_text = f"#{home_rank}" home_text = f"#{home_rank}"
else: else:
home_text = '' home_text = ""
elif self.show_records: elif self.show_records:
# Show record only when rankings are disabled # Show record only when rankings are disabled
home_text = game.get('home_record', '') home_text = game.get("home_record", "")
else: else:
home_text = '' home_text = ""
if home_text: if home_text:
home_record_bbox = draw_overlay.textbbox((0,0), home_text, font=record_font) home_record_bbox = draw_overlay.textbbox(
(0, 0), home_text, font=record_font
)
home_record_width = home_record_bbox[2] - home_record_bbox[0] home_record_width = home_record_bbox[2] - home_record_bbox[0]
home_record_x = self.display_width - home_record_width - 3 home_record_x = self.display_width - home_record_width - 3
self.logger.debug(f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}") self.logger.debug(
self._draw_text_with_outline(draw_overlay, home_text, (home_record_x, record_y), record_font) f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}"
)
self._draw_text_with_outline(
draw_overlay,
home_text,
(home_record_x, record_y),
record_font,
)
# Composite the text overlay onto the main image # Composite the text overlay onto the main image
main_img = Image.alpha_composite(main_img, overlay) main_img = Image.alpha_composite(main_img, overlay)
main_img = main_img.convert('RGB') # Convert for display main_img = main_img.convert("RGB") # Convert for display
# Display the final image # Display the final image
self.display_manager.image.paste(main_img, (0, 0)) self.display_manager.image.paste(main_img, (0, 0))
self.display_manager.update_display() # Update display here for live self.display_manager.update_display() # Update display here for live
except Exception as e: except Exception as e:
self.logger.error(f"Error displaying live Hockey game: {e}", exc_info=True) # Changed log prefix self.logger.error(
f"Error displaying live Hockey game: {e}", exc_info=True
) # Changed log prefix

View File

@@ -1,26 +1,30 @@
from typing import Dict, Any, Optional, List
from src.display_manager import DisplayManager
from src.cache_manager import CacheManager
from datetime import datetime, timedelta, timezone
import logging import logging
import os import os
from src.odds_manager import OddsManager import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Callable
import pytz
import requests import requests
from PIL import Image, ImageDraw, ImageFont
from requests.adapters import HTTPAdapter from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry from urllib3.util.retry import Retry
from PIL import Image, ImageDraw, ImageFont from abc import ABC, abstractmethod
import pytz
import time
from src.background_data_service import get_background_service from src.background_data_service import get_background_service
from src.logo_downloader import download_missing_logo, LogoDownloader
from pathlib import Path
# Import new architecture components (individual classes will import what they need) # Import new architecture components (individual classes will import what they need)
from src.base_classes.api_extractors import APIDataExtractor from src.base_classes.api_extractors import APIDataExtractor
from src.base_classes.data_sources import DataSource from src.base_classes.data_sources import DataSource
from src.cache_manager import CacheManager
from src.display_manager import DisplayManager
from src.dynamic_team_resolver import DynamicTeamResolver from src.dynamic_team_resolver import DynamicTeamResolver
from src.logo_downloader import LogoDownloader, download_missing_logo
from src.odds_manager import OddsManager
class SportsCore:
class SportsCore(ABC):
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
self.logger = logger self.logger = logger
self.config = config self.config = config
@@ -41,20 +45,21 @@ class SportsCore:
self.api_extractor: APIDataExtractor self.api_extractor: APIDataExtractor
self.data_source: DataSource self.data_source: DataSource
self.mode_config = config.get(f"{sport_key}_scoreboard", {}) # Changed config key self.mode_config = config.get(f"{sport_key}_scoreboard", {}) # Changed config key
self.is_enabled = self.mode_config.get("enabled", False) self.is_enabled: bool = self.mode_config.get("enabled", False)
self.show_odds = self.mode_config.get("show_odds", False) self.show_odds: bool = self.mode_config.get("show_odds", False)
self.test_mode = self.mode_config.get("test_mode", False) self.test_mode: bool = self.mode_config.get("test_mode", False)
self.logo_dir = Path(self.mode_config.get("logo_dir", "assets/sports/ncaa_logos")) # Changed logo dir self.logo_dir = Path(self.mode_config.get("logo_dir", "assets/sports/ncaa_logos")) # Changed logo dir
self.update_interval = self.mode_config.get( self.update_interval: int = self.mode_config.get(
"update_interval_seconds", 60) "update_interval_seconds", 60)
self.show_records = self.mode_config.get('show_records', False) self.show_records: bool = self.mode_config.get('show_records', False)
self.show_ranking = self.mode_config.get('show_ranking', False) self.show_ranking: bool = self.mode_config.get('show_ranking', False)
# Number of games to show (instead of time-based windows) # Number of games to show (instead of time-based windows)
self.recent_games_to_show = self.mode_config.get( self.recent_games_to_show: int = self.mode_config.get(
"recent_games_to_show", 5) # Show last 5 games "recent_games_to_show", 5) # Show last 5 games
self.upcoming_games_to_show = self.mode_config.get( self.upcoming_games_to_show: int = self.mode_config.get(
"upcoming_games_to_show", 10) # Show next 10 games "upcoming_games_to_show", 10) # Show next 10 games
self.show_favorite_teams_only = self.mode_config.get("show_favorite_teams_only", False) self.show_favorite_teams_only: bool = self.mode_config.get("show_favorite_teams_only", False)
self.show_all_live: bool = self.mode_config.get("show_all_live", False)
self.session = requests.Session() self.session = requests.Session()
retry_strategy = Retry( retry_strategy = Retry(
@@ -114,6 +119,9 @@ class SportsCore:
self.background_enabled = False self.background_enabled = False
self.logger.info("Background service disabled") self.logger.info("Background service disabled")
def _get_season_schedule_dates(self) -> tuple[str, str]:
return "", ""
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
"""Placeholder draw method - subclasses should override.""" """Placeholder draw method - subclasses should override."""
# This base method will be simple, subclasses provide specifics # This base method will be simple, subclasses provide specifics
@@ -314,14 +322,6 @@ class SportsCore:
if not self.show_odds: if not self.show_odds:
return return
# Check if we should only fetch for favorite teams
if self.show_favorite_teams_only:
home_abbr = game.get('home_abbr')
away_abbr = game.get('away_abbr')
if not (home_abbr in self.favorite_teams or away_abbr in self.favorite_teams):
self.logger.debug(f"Skipping odds fetch for non-favorite game in favorites-only mode: {away_abbr}@{home_abbr}")
return
# Determine update interval based on game state # Determine update interval based on game state
is_live = game.get('is_live', False) is_live = game.get('is_live', False)
update_interval = self.mode_config.get("live_odds_update_interval", 60) if is_live \ update_interval = self.mode_config.get("live_odds_update_interval", 60) if is_live \
@@ -485,16 +485,12 @@ class SportsCore:
logging.error(f"Error extracting game details: {e} from event: {game_event.get('id')}", exc_info=True) logging.error(f"Error extracting game details: {e} from event: {game_event.get('id')}", exc_info=True)
return None, None, None, None, None return None, None, None, None, None
@abstractmethod
def _extract_game_details(self, game_event: dict) -> dict | None: def _extract_game_details(self, game_event: dict) -> dict | None:
details, _, _, _, _ = self._extract_game_details_common(game_event) details, _, _, _, _ = self._extract_game_details_common(game_event)
return details return details
# def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: @abstractmethod
# pass
# def display(self, force_clear=False):
# pass
def _fetch_data(self) -> Optional[Dict]: def _fetch_data(self) -> Optional[Dict]:
pass pass
@@ -611,41 +607,9 @@ class SportsUpcoming(SportsCore):
self.logger.info(f"Found {all_upcoming_games} total upcoming games in data") self.logger.info(f"Found {all_upcoming_games} total upcoming games in data")
self.logger.info(f"Found {len(processed_games)} upcoming games after filtering") self.logger.info(f"Found {len(processed_games)} upcoming games after filtering")
# Debug: Check what statuses we're seeing if processed_games:
status_counts = {} for game in processed_games[:3]: # Show first 3
status_names = {} # Track actual status names from ESPN self.logger.info(f" {game['away_abbr']}@{game['home_abbr']} - {game['start_time_utc']}")
favorite_team_games = []
for event in events:
game = self._extract_game_details(event)
if game:
status = "upcoming" if game['is_upcoming'] else "final" if game['is_final'] else "live" if game['is_live'] else "other"
status_counts[status] = status_counts.get(status, 0) + 1
# Track actual ESPN status names
actual_status = event.get('competitions', [{}])[0].get('status', {}).get('type', {})
status_name = actual_status.get('name', 'Unknown')
status_state = actual_status.get('state', 'Unknown')
status_names[f"{status_name} ({status_state})"] = status_names.get(f"{status_name} ({status_state})", 0) + 1
# Check for favorite team games regardless of status
if (game['home_abbr'] in self.favorite_teams or game['away_abbr'] in self.favorite_teams):
favorite_team_games.append({
'teams': f"{game['away_abbr']} @ {game['home_abbr']}",
'status': status,
'date': game.get('start_time_utc', 'Unknown'),
'espn_status': f"{status_name} ({status_state})"
})
# Special check for Tennessee game (Georgia @ Tennessee)
if (game['home_abbr'] == 'TENN' and game['away_abbr'] == 'UGA') or (game['home_abbr'] == 'UGA' and game['away_abbr'] == 'TENN'):
self.logger.info(f"Found Tennessee game: {game['away_abbr']} @ {game['home_abbr']} - {status} - {game.get('start_time_utc')} - ESPN: {status_name} ({status_state})")
self.logger.info(f"Status breakdown: {status_counts}")
self.logger.info(f"ESPN status names: {status_names}")
if favorite_team_games:
self.logger.info(f"Favorite team games found: {len(favorite_team_games)}")
for game in favorite_team_games[:3]: # Show first 3
self.logger.info(f" {game['teams']} - {game['status']} - {game['date']} - ESPN: {game['espn_status']}")
if self.favorite_teams and all_upcoming_games > 0: if self.favorite_teams and all_upcoming_games > 0:
self.logger.info(f"Favorite teams: {self.favorite_teams}") self.logger.info(f"Favorite teams: {self.favorite_teams}")
@@ -653,19 +617,11 @@ class SportsUpcoming(SportsCore):
# Filter for favorite teams only if the config is set # Filter for favorite teams only if the config is set
if self.show_favorite_teams_only: if self.show_favorite_teams_only:
# Get all games involving favorite teams
favorite_team_games = [game for game in processed_games
if game['home_abbr'] in self.favorite_teams or
game['away_abbr'] in self.favorite_teams]
# Select one game per favorite team (earliest upcoming game for each team) # Select one game per favorite team (earliest upcoming game for each team)
team_games = [] team_games = []
for team in self.favorite_teams: for team in self.favorite_teams:
# Find games where this team is playing # Find games where this team is playing
team_specific_games = [game for game in favorite_team_games if team_specific_games := [game for game in processed_games if game['home_abbr'] == team or game['away_abbr'] == team]:
if game['home_abbr'] == team or game['away_abbr'] == team]
if team_specific_games:
# Sort by game time and take the earliest # Sort by game time and take the earliest
team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc)) team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc))
team_games.append(team_specific_games[0]) team_games.append(team_specific_games[0])
@@ -1201,3 +1157,143 @@ class SportsRecent(SportsCore):
except Exception as e: except Exception as e:
self.logger.error(f"Error in display loop: {e}", exc_info=True) # Changed log prefix self.logger.error(f"Error in display loop: {e}", exc_info=True) # Changed log prefix
class SportsLive(SportsCore):
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
super().__init__(config, display_manager, cache_manager, logger, sport_key)
self.update_interval = self.mode_config.get("live_update_interval", 15)
self.no_data_interval = 300
self.last_update = 0
self.live_games = []
self.current_game_index = 0
self.last_game_switch = 0
self.game_display_duration = self.mode_config.get("live_game_duration", 20)
self.last_display_update = 0
self.last_log_time = 0
self.log_interval = 300
@abstractmethod
def _test_mode_update(self) -> None:
return
def update(self):
"""Update live game data and handle game switching."""
if not self.is_enabled:
return
# Define current_time and interval before the problematic line (originally line 455)
# Ensure 'import time' is present at the top of the file.
current_time = time.time()
# Define interval using a pattern similar to NFLLiveManager's update method.
# Uses getattr for robustness, assuming attributes for live_games, test_mode,
# no_data_interval, and update_interval are available on self.
_live_games_attr = self.live_games
_test_mode_attr = self.test_mode # test_mode is often from a base class or config
_no_data_interval_attr = self.no_data_interval # Default similar to NFLLiveManager
_update_interval_attr = self.update_interval # Default similar to NFLLiveManager
interval = _no_data_interval_attr if not _live_games_attr and not _test_mode_attr else _update_interval_attr
# Original line from traceback (line 455), now with variables defined:
if current_time - self.last_update >= interval:
self.last_update = current_time
# Fetch rankings if enabled
if self.show_ranking:
self._fetch_team_rankings()
if self.test_mode:
# Simulate clock running down in test mode
self._test_mode_update()
else:
# Fetch live game data
data = self._fetch_data()
new_live_games = []
if data and "events" in data:
for game in data["events"]:
details = self._extract_game_details(game)
if details and (details["is_live"] or details["is_halftime"]):
# If show_favorite_teams_only is true, only add if it's a favorite.
# Otherwise, add all games.
if self.show_all_live or not self.show_favorite_teams_only or (self.show_favorite_teams_only and (details["home_abbr"] in self.favorite_teams or details["away_abbr"] in self.favorite_teams)):
if self.show_odds:
self._fetch_odds(details)
new_live_games.append(details)
# Log changes or periodically
current_time_for_log = time.time() # Use a consistent time for logging comparison
should_log = (
current_time_for_log - self.last_log_time >= self.log_interval or
len(new_live_games) != len(self.live_games) or
any(g1['id'] != g2.get('id') for g1, g2 in zip(self.live_games, new_live_games)) or # Check if game IDs changed
(not self.live_games and new_live_games) # Log if games appeared
)
if should_log:
if new_live_games:
filter_text = "favorite teams" if self.show_favorite_teams_only or self.show_all_live else "all teams"
self.logger.info(f"Found {len(new_live_games)} live/halftime games for {filter_text}.")
for game_info in new_live_games: # Renamed game to game_info
self.logger.info(f" - {game_info['away_abbr']}@{game_info['home_abbr']} ({game_info.get('status_text', 'N/A')})")
else:
filter_text = "favorite teams" if self.show_favorite_teams_only or self.show_all_live else "criteria"
self.logger.info(f"No live/halftime games found for {filter_text}.")
self.last_log_time = current_time_for_log
# Update game list and current game
if new_live_games:
# Check if the games themselves changed, not just scores/time
new_game_ids = {g['id'] for g in new_live_games}
current_game_ids = {g['id'] for g in self.live_games}
if new_game_ids != current_game_ids:
self.live_games = sorted(new_live_games, key=lambda g: g.get('start_time_utc') or datetime.now(timezone.utc)) # Sort by start time
# Reset index if current game is gone or list is new
if not self.current_game or self.current_game['id'] not in new_game_ids:
self.current_game_index = 0
self.current_game = self.live_games[0] if self.live_games else None
self.last_game_switch = current_time
else:
# Find current game's new index if it still exists
try:
self.current_game_index = next(i for i, g in enumerate(self.live_games) if g['id'] == self.current_game['id'])
self.current_game = self.live_games[self.current_game_index] # Update current_game with fresh data
except StopIteration: # Should not happen if check above passed, but safety first
self.current_game_index = 0
self.current_game = self.live_games[0]
self.last_game_switch = current_time
else:
# Just update the data for the existing games
temp_game_dict = {g['id']: g for g in new_live_games}
self.live_games = [temp_game_dict.get(g['id'], g) for g in self.live_games] # Update in place
if self.current_game:
self.current_game = temp_game_dict.get(self.current_game['id'], self.current_game)
# Display update handled by main loop based on interval
else:
# No live games found
if self.live_games: # Were there games before?
self.logger.info("Live games previously showing have ended or are no longer live.") # Changed log prefix
self.live_games = []
self.current_game = None
self.current_game_index = 0
else:
# Error fetching data or no events
if self.live_games: # Were there games before?
self.logger.warning("Could not fetch update; keeping existing live game data for now.") # Changed log prefix
else:
self.logger.warning("Could not fetch data and no existing live games.") # Changed log prefix
self.current_game = None # Clear current game if fetch fails and no games were active
# Handle game switching (outside test mode check)
if not self.test_mode and len(self.live_games) > 1 and (current_time - self.last_game_switch) >= self.game_display_duration:
self.current_game_index = (self.current_game_index + 1) % len(self.live_games)
self.current_game = self.live_games[self.current_game_index]
self.last_game_switch = current_time
self.logger.info(f"Switched live view to: {self.current_game['away_abbr']}@{self.current_game['home_abbr']}") # Changed log prefix
# Force display update via flag or direct call if needed, but usually let main loop handle

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -48,68 +48,115 @@ class BaseNCAAMHockeyManager(Hockey): # Renamed class
self.logger.info(f"Logo directory: {self.logo_dir}") self.logger.info(f"Logo directory: {self.logo_dir}")
self.logger.info(f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}") self.logger.info(f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}")
def _get_timezone(self):
try:
timezone_str = self.config.get('timezone', 'UTC')
return pytz.timezone(timezone_str)
except pytz.UnknownTimeZoneError:
return pytz.utc
def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: def _fetch_ncaa_hockey_api_data(self, use_cache: bool = True) -> Optional[Dict]:
"""Check if we should log a warning based on cooldown period."""
current_time = time.time()
if current_time - self._last_warning_time > cooldown:
self._last_warning_time = current_time
return True
return False
def _fetch_ncaa_fb_api_data(self, use_cache: bool = True) -> Optional[Dict]:
""" """
Fetches the full season schedule for NCAAMH, caches it, and then filters Fetches the full season schedule for NCAAMH, caches it, and then filters
for relevant games based on the current configuration. for relevant games based on the current configuration.
""" """
now = datetime.now(pytz.utc) now = datetime.now(pytz.utc)
current_year = now.year season_year = now.year
years_to_check = [current_year]
if now.month < 8: if now.month < 8:
years_to_check.append(current_year - 1) season_year = now.year - 1
datestring = f"{season_year}0901-{season_year+1}0501"
cache_key = f"ncaa_mens_hockey_schedule_{season_year}"
all_events = []
for year in years_to_check:
cache_key = f"ncaamh_schedule_{year}"
if use_cache: if use_cache:
cached_data = self.cache_manager.get(cache_key) cached_data = self.cache_manager.get(cache_key)
if cached_data: if cached_data:
self.logger.info(f"[NCAAMH] Using cached schedule for {year}") # Validate cached data structure
all_events.extend(cached_data) if isinstance(cached_data, dict) and 'events' in cached_data:
continue self.logger.info(f"Using cached schedule for {season_year}")
return cached_data
elif isinstance(cached_data, list):
# Handle old cache format (list of events)
self.logger.info(f"Using cached schedule for {season_year} (legacy format)")
return {'events': cached_data}
else:
self.logger.warning(f"Invalid cached data format for {season_year}: {type(cached_data)}")
# Clear invalid cache
self.cache_manager.clear_cache(cache_key)
self.logger.info(f"[NCAAMH] Fetching full {year} season schedule from ESPN API...") # If background service is disabled, fall back to synchronous fetch
if not self.background_enabled or not self.background_service:
return self._fetch_ncaa_api_data_sync(use_cache)
self.logger.info(f"Fetching full {season_year} season schedule from ESPN API...")
# Start background fetch
self.logger.info(f"Starting background fetch for {season_year} season schedule...")
def fetch_callback(result):
"""Callback when background fetch completes."""
if result.success:
self.logger.info(f"Background fetch completed for {season_year}: {len(result.data.get('events'))} events")
else:
self.logger.error(f"Background fetch failed for {season_year}: {result.error}")
# Clean up request tracking
if season_year in self.background_fetch_requests:
del self.background_fetch_requests[season_year]
# Get background service configuration
background_config = self.mode_config.get("background_service", {})
timeout = background_config.get("request_timeout", 30)
max_retries = background_config.get("max_retries", 3)
priority = background_config.get("priority", 2)
# Submit background fetch request
request_id = self.background_service.submit_fetch_request(
sport="ncaa_mens_hockey",
year=season_year,
url=ESPN_NCAAMH_SCOREBOARD_URL,
cache_key=cache_key,
params={"dates": datestring, "limit": 1000},
headers=self.headers,
timeout=timeout,
max_retries=max_retries,
priority=priority,
callback=fetch_callback
)
# Track the request
self.background_fetch_requests[season_year] = request_id
# For immediate response, try to get partial data
partial_data = self._get_weeks_data()
if partial_data:
return partial_data
return None
def _fetch_ncaa_api_data_sync(self, use_cache: bool = True) -> Optional[Dict]:
"""
Synchronous fallback for fetching NCAA Mens Hockey data when background service is disabled.
"""
now = datetime.now(pytz.utc)
current_year = now.year
cache_key = f"ncaa_mens_hockey_schedule_{current_year}"
self.logger.info(f"Fetching full {current_year} season schedule from ESPN API (sync mode)...")
try: try:
response = self.session.get(ESPN_NCAAMH_SCOREBOARD_URL, params={"dates": year,"limit":1000},headers=self.headers, timeout=15) response = self.session.get(ESPN_NCAAMH_SCOREBOARD_URL, params={"dates": current_year, "limit":1000}, headers=self.headers, timeout=15)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
events = data.get('events', []) events = data.get('events', [])
if use_cache: if use_cache:
self.cache_manager.set(cache_key, events) self.cache_manager.set(cache_key, events)
self.logger.info(f"[NCAAMH] Successfully fetched and cached {len(events)} events for {year} season.")
all_events.extend(events) self.logger.info(f"Successfully fetched {len(events)} events for the {current_year} season.")
return {'events': events}
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
self.logger.error(f"[NCAAMH] API error fetching full schedule for {year}: {e}") self.logger.error(f"[API error fetching full schedule: {e}")
continue
if not all_events:
self.logger.warning("[NCAAMH] No events found in schedule data.")
return None return None
return {'events': all_events}
def _fetch_data(self) -> Optional[Dict]: def _fetch_data(self) -> Optional[Dict]:
"""Fetch data using shared data mechanism or direct fetch for live.""" """Fetch data using shared data mechanism or direct fetch for live."""
if isinstance(self, NCAAMHockeyLiveManager): if isinstance(self, NCAAMHockeyLiveManager):
return self._fetch_ncaa_fb_api_data(use_cache=False) return self._fetch_todays_games()
else: else:
return self._fetch_ncaa_fb_api_data(use_cache=True) return self._fetch_ncaa_hockey_api_data(use_cache=True)
class NCAAMHockeyLiveManager(BaseNCAAMHockeyManager, HockeyLive): # Renamed class class NCAAMHockeyLiveManager(BaseNCAAMHockeyManager, HockeyLive): # Renamed class
"""Manager for live NCAA Mens Hockey games.""" """Manager for live NCAA Mens Hockey games."""
@@ -133,7 +180,8 @@ class NCAAMHockeyLiveManager(BaseNCAAMHockeyManager, HockeyLive): # Renamed clas
"home_logo_path": Path(self.logo_dir, "RIT.png"), "home_logo_path": Path(self.logo_dir, "RIT.png"),
"away_logo_path": Path(self.logo_dir, "CLAR .png"), "away_logo_path": Path(self.logo_dir, "CLAR .png"),
"game_time": "7:30 PM", "game_time": "7:30 PM",
"game_date": "Apr 17" "game_date": "Apr 17",
"is_live": True, "is_final": False, "is_upcoming": False,
} }
self.live_games = [self.current_game] self.live_games = [self.current_game]
self.logger.info("Initialized NCAAMHockeyLiveManager with test game: RIT vs CLAR ") self.logger.info("Initialized NCAAMHockeyLiveManager with test game: RIT vs CLAR ")

View File

@@ -1,14 +1,16 @@
import os
import logging import logging
import requests import os
from typing import Dict, Any, Optional, List
from pathlib import Path
from datetime import datetime, timedelta from datetime import datetime, timedelta
from src.display_manager import DisplayManager from pathlib import Path
from src.cache_manager import CacheManager from typing import Any, Dict, List, Optional
import pytz import pytz
from src.base_classes.sports import SportsRecent, SportsUpcoming import requests
from src.base_classes.football import Football, FootballLive from src.base_classes.football import Football, FootballLive
from src.base_classes.sports import SportsRecent, SportsUpcoming
from src.cache_manager import CacheManager
from src.display_manager import DisplayManager
# Constants # Constants
ESPN_NFL_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard" ESPN_NFL_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard"
@@ -50,7 +52,7 @@ class BaseNFLManager(Football): # Renamed class
if now.month < 8: if now.month < 8:
season_year = now.year - 1 season_year = now.year - 1
datestring = f"{season_year}0801-{season_year+1}0301" datestring = f"{season_year}0801-{season_year+1}0301"
cache_key = f"nfl_schedule_{season_year}" cache_key = f"{self.sport_key}_schedule_{season_year}"
# Check cache first # Check cache first
if use_cache: if use_cache:
@@ -58,14 +60,14 @@ class BaseNFLManager(Football): # Renamed class
if cached_data: if cached_data:
# Validate cached data structure # Validate cached data structure
if isinstance(cached_data, dict) and 'events' in cached_data: if isinstance(cached_data, dict) and 'events' in cached_data:
self.logger.info(f"[NFL] Using cached schedule for {season_year}") self.logger.info(f"Using cached schedule for {season_year}")
return cached_data return cached_data
elif isinstance(cached_data, list): elif isinstance(cached_data, list):
# Handle old cache format (list of events) # Handle old cache format (list of events)
self.logger.info(f"[NFL] Using cached schedule for {season_year} (legacy format)") self.logger.info(f"Using cached schedule for {season_year} (legacy format)")
return {'events': cached_data} return {'events': cached_data}
else: else:
self.logger.warning(f"[NFL] Invalid cached data format for {season_year}: {type(cached_data)}") self.logger.warning(f"Invalid cached data format for {season_year}: {type(cached_data)}")
# Clear invalid cache # Clear invalid cache
self.cache_manager.clear_cache(cache_key) self.cache_manager.clear_cache(cache_key)
@@ -74,7 +76,7 @@ class BaseNFLManager(Football): # Renamed class
return self._fetch_nfl_api_data_sync(use_cache) return self._fetch_nfl_api_data_sync(use_cache)
# Start background fetch # Start background fetch
self.logger.info(f"[NFL] Starting background fetch for {season_year} season schedule...") self.logger.info(f"Starting background fetch for {season_year} season schedule...")
def fetch_callback(result): def fetch_callback(result):
"""Callback when background fetch completes.""" """Callback when background fetch completes."""
@@ -125,7 +127,7 @@ class BaseNFLManager(Football): # Renamed class
current_year = now.year current_year = now.year
cache_key = f"nfl_schedule_{current_year}" cache_key = f"nfl_schedule_{current_year}"
self.logger.info(f"[NFL] Fetching full {current_year} season schedule from ESPN API (sync mode)...") self.logger.info(f"Fetching full {current_year} season schedule from ESPN API (sync mode)...")
try: try:
response = self.session.get(ESPN_NFL_SCOREBOARD_URL, params={"dates": current_year, "limit":1000}, headers=self.headers, timeout=15) response = self.session.get(ESPN_NFL_SCOREBOARD_URL, params={"dates": current_year, "limit":1000}, headers=self.headers, timeout=15)
response.raise_for_status() response.raise_for_status()
@@ -135,10 +137,10 @@ class BaseNFLManager(Football): # Renamed class
if use_cache: if use_cache:
self.cache_manager.set(cache_key, events) self.cache_manager.set(cache_key, events)
self.logger.info(f"[NFL] Successfully fetched {len(events)} events for the {current_year} season.") self.logger.info(f"Successfully fetched {len(events)} events for the {current_year} season.")
return {'events': events} return {'events': events}
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
self.logger.error(f"[NFL] API error fetching full schedule: {e}") self.logger.error(f"API error fetching full schedule: {e}")
return None return None
def _fetch_data(self) -> Optional[Dict]: def _fetch_data(self) -> Optional[Dict]:

File diff suppressed because it is too large Load Diff