mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-11 13:23:00 +00:00
Create Basketball Base Class and Consolidate Basketball Managers (#97)
* Create basketball Base class * Migrate NBA * Add NCAA Mens Basketball * Add NCAA Women's Basketball * Add WNBA --------- Co-authored-by: Alex Resnick <adr8282@gmail.com>
This commit is contained in:
309
src/base_classes/basketball.py
Normal file
309
src/base_classes/basketball.py
Normal file
@@ -0,0 +1,309 @@
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
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 Basketball(SportsCore):
|
||||
"""Base class for basketball sports with common functionality."""
|
||||
|
||||
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.data_source = ESPNDataSource(logger)
|
||||
self.sport = "basketball"
|
||||
|
||||
def _extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
||||
"""Extract relevant game details from ESPN NCAA FB API response."""
|
||||
# --- THIS METHOD MAY NEED ADJUSTMENTS FOR NCAA FB API DIFFERENCES ---
|
||||
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:
|
||||
# Format period/quarter
|
||||
period = status.get("period", 0)
|
||||
period_text = ""
|
||||
if status["type"]["state"] == "in":
|
||||
if period == 0:
|
||||
period_text = "Start" # Before kickoff
|
||||
elif period >= 1 and period <= 4:
|
||||
period_text = f"Q{period}" # OT starts after Q4
|
||||
elif period > 4:
|
||||
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
|
||||
period_text = "HALF"
|
||||
elif status["type"]["state"] == "post":
|
||||
if period > 4 : period_text = "Final/OT"
|
||||
else: period_text = "Final"
|
||||
elif status["type"]["state"] == "pre":
|
||||
period_text = details.get("game_time", "") # Show time for upcoming
|
||||
|
||||
details.update({
|
||||
"period": period,
|
||||
"period_text": period_text, # Formatted quarter/status
|
||||
"clock": status.get("displayClock", "0:00"),
|
||||
})
|
||||
|
||||
# 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 BasketballLive(Basketball, 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)
|
||||
|
||||
def _test_mode_update(self):
|
||||
if self.current_game and self.current_game["is_live"]:
|
||||
# For testing, we'll just update the clock to show it's working
|
||||
minutes = int(self.current_game["clock"].split(":")[0])
|
||||
seconds = int(self.current_game["clock"].split(":")[1])
|
||||
seconds -= 1
|
||||
if seconds < 0:
|
||||
seconds = 59
|
||||
minutes -= 1
|
||||
if minutes < 0:
|
||||
minutes = 19
|
||||
if self.current_game["period"] < 3:
|
||||
self.current_game["period"] += 1
|
||||
else:
|
||||
self.current_game["period"] = 1
|
||||
self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}"
|
||||
# Always update display in test mode
|
||||
|
||||
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
||||
"""Draw the detailed scorebug layout for a live Basketball game.""" # Updated docstring
|
||||
try:
|
||||
main_img = Image.new(
|
||||
"RGBA", (self.display_width, self.display_height), (0, 0, 0, 255)
|
||||
)
|
||||
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
|
||||
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:
|
||||
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)
|
||||
|
||||
# --- Draw Text Elements on Overlay ---
|
||||
# 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()
|
||||
)
|
||||
|
||||
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)
|
||||
home_score = str(game.get("home_score", "0"))
|
||||
away_score = str(game.get("away_score", "0"))
|
||||
score_text = f"{away_score}-{home_score}"
|
||||
score_width = draw_overlay.textlength(score_text, font=self.fonts["score"])
|
||||
score_x = (self.display_width - score_width) // 2
|
||||
score_y = (
|
||||
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"]
|
||||
)
|
||||
|
||||
# Draw odds if available
|
||||
if "odds" in game and game["odds"]:
|
||||
self._draw_dynamic_odds(
|
||||
draw_overlay, game["odds"], self.display_width, self.display_height
|
||||
)
|
||||
|
||||
# Draw records or rankings if enabled
|
||||
if self.show_records or self.show_ranking:
|
||||
try:
|
||||
record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6)
|
||||
self.logger.debug(f"Loaded 6px record font successfully")
|
||||
except IOError:
|
||||
record_font = ImageFont.load_default()
|
||||
self.logger.warning(
|
||||
f"Failed to load 6px font, using default font (size: {record_font.size})"
|
||||
)
|
||||
|
||||
# Get team abbreviations
|
||||
away_abbr = game.get("away_abbr", "")
|
||||
home_abbr = game.get("home_abbr", "")
|
||||
|
||||
record_bbox = draw_overlay.textbbox((0, 0), "0-0", font=record_font)
|
||||
record_height = record_bbox[3] - record_bbox[1]
|
||||
record_y = self.display_height - record_height - 1
|
||||
self.logger.debug(
|
||||
f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}"
|
||||
)
|
||||
|
||||
# Display away team info
|
||||
if away_abbr:
|
||||
if self.show_ranking and self.show_records:
|
||||
# When both rankings and records are enabled, rankings replace records completely
|
||||
away_rank = self._team_rankings_cache.get(away_abbr, 0)
|
||||
if away_rank > 0:
|
||||
away_text = f"#{away_rank}"
|
||||
else:
|
||||
# Show nothing for unranked teams when rankings are prioritized
|
||||
away_text = ""
|
||||
elif self.show_ranking:
|
||||
# Show ranking only if available
|
||||
away_rank = self._team_rankings_cache.get(away_abbr, 0)
|
||||
if away_rank > 0:
|
||||
away_text = f"#{away_rank}"
|
||||
else:
|
||||
away_text = ""
|
||||
elif self.show_records:
|
||||
# Show record only when rankings are disabled
|
||||
away_text = game.get("away_record", "")
|
||||
else:
|
||||
away_text = ""
|
||||
|
||||
if away_text:
|
||||
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._draw_text_with_outline(
|
||||
draw_overlay,
|
||||
away_text,
|
||||
(away_record_x, record_y),
|
||||
record_font,
|
||||
)
|
||||
|
||||
# Display home team info
|
||||
if home_abbr:
|
||||
if self.show_ranking and self.show_records:
|
||||
# When both rankings and records are enabled, rankings replace records completely
|
||||
home_rank = self._team_rankings_cache.get(home_abbr, 0)
|
||||
if home_rank > 0:
|
||||
home_text = f"#{home_rank}"
|
||||
else:
|
||||
# Show nothing for unranked teams when rankings are prioritized
|
||||
home_text = ""
|
||||
elif self.show_ranking:
|
||||
# Show ranking only if available
|
||||
home_rank = self._team_rankings_cache.get(home_abbr, 0)
|
||||
if home_rank > 0:
|
||||
home_text = f"#{home_rank}"
|
||||
else:
|
||||
home_text = ""
|
||||
elif self.show_records:
|
||||
# Show record only when rankings are disabled
|
||||
home_text = game.get("home_record", "")
|
||||
else:
|
||||
home_text = ""
|
||||
|
||||
if home_text:
|
||||
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_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._draw_text_with_outline(
|
||||
draw_overlay,
|
||||
home_text,
|
||||
(home_record_x, record_y),
|
||||
record_font,
|
||||
)
|
||||
|
||||
# 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:
|
||||
self.logger.error(
|
||||
f"Error displaying live Hockey game: {e}", exc_info=True
|
||||
) # Changed log prefix
|
||||
Reference in New Issue
Block a user