diff --git a/config/config.template.json b/config/config.template.json index 29113d46..3dbd6df3 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -573,6 +573,29 @@ "soccer_upcoming": true } }, + "ufc_scoreboard": { + "enabled": false, + "live_priority": true, + "live_game_duration": 30, + "show_odds": true, + "test_mode": false, + "update_interval_seconds": 3600, + "live_update_interval": 30, + "recent_update_interval": 3600, + "upcoming_update_interval": 3600, + "recent_games_to_show": 1, + "upcoming_games_to_show": 1, + "show_favorite_teams_only": true, + "favorite_fighters": [], + "weigth_class": [], + "logo_dir": "assets/sports/ufc_logos", + "show_records": true, + "display_modes": { + "ufc_live": true, + "ufc_recent": true, + "ufc_upcoming": true + } + }, "music": { "enabled": false, "preferred_source": "ytm", diff --git a/src/base_classes/mma.py b/src/base_classes/mma.py new file mode 100644 index 00000000..bc929a28 --- /dev/null +++ b/src/base_classes/mma.py @@ -0,0 +1,1200 @@ +import logging +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +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, SportsRecent, SportsUpcoming +from src.cache_manager import CacheManager +from src.display_manager import DisplayManager +from src.logo_downloader import LogoDownloader + + +class MMA(SportsCore): + """Base class for MMA 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 = "mma" + self.favorite_fighters = [ + f.lower() for f in self.mode_config.get("favorite_fighters", []) + ] + self.favorite_weight_class = [ + wc.lower() for wc in self.mode_config.get("favorite_weight_class", []) + ] + + def _load_and_resize_logo( + self, fighter_id: str, fighter_name: str, logo_path: Path, logo_url: str + ) -> Optional[Image.Image]: + """Load and resize a team logo, with caching and automatic download if missing.""" + self.logger.debug(f"Logo path: {logo_path}") + if fighter_id in self._logo_cache: + self.logger.debug(f"Using cached logo for {fighter_name}") + return self._logo_cache[fighter_id] + + try: + # If no variation found, try to download missing logo + if not logo_path.exists(): + self.logger.info( + f"Logo not found for {fighter_name} at {logo_path}. Attempting to download." + ) + # Try to download the logo from ESPN API (this will create placeholder if download fails) + + # Get logo directory + if not self.logo_dir.exists(): + self.logo_dir.mkdir() + + response = self.session.get(logo_url, headers=self.headers, timeout=120) + response.raise_for_status() + # Verify it's actually an image + content_type = response.headers.get("content-type", "").lower() + if not any( + img_type in content_type + for img_type in [ + "image/png", + "image/jpeg", + "image/jpg", + "image/gif", + ] + ): + self.logger.warning( + f"Downloaded content for {fighter_name} is not an image: {content_type}" + ) + return + + with logo_path.open(mode="wb") as f: + f.write(response.content) + + # Verify and convert the downloaded image to RGBA format + try: + with Image.open(logo_path) as img: + # Convert to RGBA to avoid PIL warnings about palette images with transparency + if img.mode in ("P", "LA", "L"): + # Convert palette or grayscale images to RGBA + img = img.convert("RGBA") + elif img.mode == "RGB": + # Convert RGB to RGBA (add alpha channel) + img = img.convert("RGBA") + elif img.mode != "RGBA": + # For any other mode, convert to RGBA + img = img.convert("RGBA") + + # Save the converted image + img.save(logo_path, "PNG") + + self.logger.info( + f"Successfully downloaded and converted logo for {fighter_name} -> {logo_path.name}" + ) + except Exception as e: + self.logger.error( + f"Downloaded file for {fighter_name} is not a valid image or conversion failed: {e}" + ) + try: + logo_path.unlink() # Remove invalid file + except: + pass + return + # Only try to open the logo if the file exists + if logo_path.exists(): + logo = Image.open(logo_path) + else: + self.logger.error( + f"Logo file still doesn't exist at {logo_path} after download attempt" + ) + return None + if logo.mode != "RGBA": + logo = logo.convert("RGBA") + + max_width = int(self.display_width * 1.5) + max_height = int(self.display_height * 1.5) + logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + self._logo_cache[fighter_id] = logo + return logo + + except Exception as e: + self.logger.error( + f"Error loading logo for {fighter_name}: {e}", exc_info=True + ) + return None + + def _extract_game_details(self, game_event: dict) -> Dict | None: + if not game_event: + return None + try: + competition = game_event["competitions"][0] + status = competition["status"] + competitors = competition["competitors"] + game_date_str = game_event["date"] + start_time_utc = None + try: + start_time_utc = datetime.fromisoformat( + game_date_str.replace("Z", "+00:00") + ) + except ValueError: + logging.warning(f"Could not parse game date: {game_date_str}") + + try: + fight_class = competition["type"]["abbreviation"] + except KeyError: + fight_class = "" + + fighter1 = next((c for c in competitors if c.get("order") == 1), None) + fighter2 = next((c for c in competitors if c.get("order") == 2), None) + + if not fighter1 or not fighter2: + self.logger.warning( + f"Could not find Fighter 1 or 2 in event: {competition.get('id')}" + ) + return None + + try: + fighter1_name = fighter1["athlete"]["fullName"] + fighter1_name_short = fighter1["athlete"]["shortName"] + except KeyError: + fighter1_name = "" + fighter1_name_short = "" + try: + fighter2_name = fighter2["athlete"]["fullName"] + fighter2_name_short = fighter2["athlete"]["shortName"] + except KeyError: + fighter2_name = "" + fighter2_name_short = "" + + # Check if this is a favorite team game BEFORE doing expensive logging + is_favorite_game = ( + fighter1_name.lower() in self.favorite_fighters + or fighter2_name.lower() in self.favorite_fighters + ) or fight_class.lower() in self.favorite_weight_class + + # Only log debug info for favorite team games + if is_favorite_game: + self.logger.debug( + f"Processing favorite fights: {competition.get('id')}" + ) + self.logger.debug( + f"Found teams: {fighter1_name} vs {fighter2_name}, Status: {status['type']['name']}, State: {status['type']['state']}" + ) + + game_time, game_date = "", "" + if start_time_utc: + local_time = start_time_utc.astimezone(self._get_timezone()) + game_time = local_time.strftime("%I:%M%p").lstrip("0") + + # Check date format from config + use_short_date_format = self.config.get("display", {}).get( + "use_short_date_format", False + ) + if use_short_date_format: + game_date = local_time.strftime("%-m/%-d") + else: + game_date = self.display_manager.format_date_with_ordinal( + local_time + ) + + fighter1_record = ( + fighter1.get("records", [{}])[0].get("summary", "") + if fighter1.get("records") + else "" + ) + fighter2_record = ( + fighter2.get("records", [{}])[0].get("summary", "") + if fighter2.get("records") + else "" + ) + + # Don't show "0-0" records - set to blank instead + if fighter1_record in {"0-0", "0-0-0"}: + fighter1_record = "" + if fighter2_record in {"0-0", "0-0-0"}: + fighter2_record = "" + + details = { + "event_id": game_event.get("id"), + "comp_id": competition.get("id"), + "id": competition.get("id"), + "game_time": game_time, + "game_date": game_date, + "start_time_utc": start_time_utc, + "status_text": status["type"][ + "shortDetail" + ], # e.g., "Final", "7:30 PM", "Q1 12:34" + "is_live": status["type"]["state"] == "in", + "is_final": status["type"]["state"] == "post", + "is_upcoming": ( + status["type"]["state"] == "pre" + or status["type"]["name"].lower() + in ["scheduled", "pre-game", "status_scheduled"] + ), + "is_period_break": status["type"]["name"] + == "STATUS_END_PERIOD", # Added Period Break check + "fight_class": fight_class, + "fighter1_name": fighter1_name, + "fighter1_name_short": fighter1_name_short, + "fighter1_id": fighter1["id"], + # "home_score": home_team.get("score", "0"), + "fighter1_image_path": self.logo_dir + / Path(f"{fighter1.get('id')}.png"), + "fighter1_image_url": f"https://a.espncdn.com/combiner/i?img=/i/headshots/mma/players/full/{fighter1.get('id')}.png", + "fighter1_country_url": fighter1.get("athlete", {}) + .get("flag", {}) + .get("href", ""), + "fighter1_record": fighter1_record, + "fighter2_name": fighter2_name, + "fighter2_name_short": fighter2_name_short, + "fighter2_id": fighter2["id"], + # "home_score": home_team.get("score", "0"), + "fighter2_image_path": self.logo_dir + / Path(f"{fighter2.get('id')}.png"), + "fighter2_image_url": f"https://a.espncdn.com/combiner/i?img=/i/headshots/mma/players/full/{fighter2.get('id')}.png", + "fighter2_country_url": fighter2.get("athlete", {}) + .get("flag", {}) + .get("href", ""), + "fighter2_record": fighter2_record, + "is_within_window": True, # Whether game is within display window + } + return details + except Exception as e: + # Log the problematic event structure if possible + logging.error( + f"Error extracting game details: {e} from event: {game_event.get('event_id')} - {competition.get('id')}", + exc_info=True, + ) + return None + + +class MMARecent(MMA, SportsRecent): + 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 _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: + """Draw the layout for a recently completed NCAA FB 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) + + fighter1_image = self._load_and_resize_logo( + game["fighter1_id"], + game["fighter1_name"], + game["fighter1_image_path"], + game["fighter1_image_url"], + ) + fighter2_image = self._load_and_resize_logo( + game["fighter2_id"], + game["fighter2_name"], + game["fighter2_image_path"], + game["fighter2_image_url"], + ) + + # fighter1_flag_image = self._load_and_resize_logo( + # game["fighter1_id"], + # game["fighter1_name"], + # game["fighter1_image_path"], + # game["fighter1_country_url"], + # ) + # fighter2_flag_image = self._load_and_resize_logo( + # game["fighter1_id"]+"_flag", + # game["fighter1_name"], + # game["fighter1_image_path"], + # game["fighter2_country_url"], + # ) + + if not fighter1_image or not fighter2_image: + self.logger.error( + f"Failed to load logos for game: {game.get('id')}" + ) # Changed log prefix + 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 + + # MLB-style logo positions + home_x = ( + self.display_width + - fighter1_image.width + + fighter1_image.width // 4 + + 2 + ) + home_y = center_y - (fighter1_image.height // 2) + main_img.paste(fighter1_image, (home_x, home_y), fighter1_image) + + away_x = -2 - fighter2_image.width // 4 + away_y = center_y - (fighter2_image.height // 2) + main_img.paste(fighter2_image, (away_x, away_y), fighter2_image) + # Final Scores (Centered, same position as live) + score_text = f"Some score here" + score_width = draw_overlay.textlength(score_text, font=self.fonts["score"]) + score_x = (self.display_width - score_width) // 2 + score_y = self.display_height - 14 + self._draw_text_with_outline( + draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + ) + + # "Final" text (Top center) + status_text = game.get( + "period_text", "Final" + ) # Use formatted period text (e.g., "Final/OT") or default "Final" + status_width = draw_overlay.textlength(status_text, font=self.fonts["time"]) + status_x = (self.display_width - status_width) // 2 + status_y = 1 + self._draw_text_with_outline( + draw_overlay, status_text, (status_x, status_y), self.fonts["time"] + ) + + 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: + 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 + fighter1_record = game.get("fighter1_record", "") + fighter2_record = game.get("fighter2_record", "") + + record_bbox = draw_overlay.textbbox((0, 0), "0-0-0", font=record_font) + record_height = record_bbox[3] - record_bbox[1] + record_y = self.display_height - record_height + self.logger.debug( + f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}" + ) + + # Display away team info + if fighter1_record: + away_text = fighter1_record + away_record_x = 0 + 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 fighter2_record: + home_text = fighter2_record + 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 + 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, home_text, (home_record_x, record_y), record_font + ) + + self._custom_scorebug_layout(game, draw_overlay) + # Composite and display + main_img = Image.alpha_composite(main_img, overlay) + main_img = main_img.convert("RGB") + self.display_manager.image.paste(main_img, (0, 0)) + self.display_manager.update_display() # Update display here + + except Exception as e: + self.logger.error( + f"Error displaying recent game: {e}", exc_info=True + ) # Changed log prefix + + def update(self): + """Update recent games data.""" + if not self.is_enabled: + return + current_time = time.time() + if current_time - self.last_update < self.update_interval: + return + + self.last_update = current_time # Update time even if fetch fails + + try: + data = self._fetch_data() # Uses shared cache + if not data or "events" not in data: + self.logger.warning( + "No events found in shared data." + ) # Changed log prefix + if not self.games_list: + self.current_game = None # Clear display if no games were showing + return + + events = data["events"] + self.logger.info( + f"Processing {len(events)} events from shared data." + ) # Changed log prefix + + # Define date range for "recent" games (last 21 days to capture games from 3 weeks ago) + now = datetime.now(timezone.utc) + recent_cutoff = now - timedelta(days=21) + self.logger.info( + f"Current time: {now}, Recent cutoff: {recent_cutoff} (21 days ago)" + ) + + # Process games and filter for final games, date range & favorite teams + processed_games = [] + flattened_events = [ + { + **{k: v for k, v in event.items() if k != "competitions"}, + "competitions": [comp], + } + for event in data["events"] + for comp in event.get("competitions", []) + ] + for event in flattened_events: + game = self._extract_game_details(event) + # Filter criteria: must be final AND within recent date range + if game and game["is_final"]: + game_time = game.get("start_time_utc") + if game_time and game_time >= recent_cutoff: + processed_games.append(game) + # Filter for favorite teams + if self.favorite_teams: + # Get all games involving favorite teams + favorite_team_games = [ + game + for game in processed_games + if ( + game["fighter1_name"].lower() in self.favorite_fighters + or game["fighter2_name"].lower() in self.favorite_fighters + ) + or game["fight_class"].lower() in self.favorite_weight_class + ] + self.logger.info( + f"Found {len(favorite_team_games)} favorite team games out of {len(processed_games)} total final games within last 21 days" + ) + + # Sort the final list by game time (most recent first) + favorite_team_games.sort( + key=lambda g: g.get("start_time_utc") + or datetime.min.replace(tzinfo=timezone.utc), + reverse=True, + ) + + # Select one game per favorite team (most recent game for each team) + team_games = [] + for fighter in self.favorite_fighters: + # Find games where this team is playing + team_specific_games = [ + game + for game in favorite_team_games + if ( + game["fighter1_name"].lower() == fighter.lower() + or game["fighter2_name"].lower() == fighter.lower() + ) + ] + + if team_specific_games: + # Sort by game time and take the most recent + team_specific_games.sort( + key=lambda g: g.get("start_time_utc") + or datetime.min.replace(tzinfo=timezone.utc), + reverse=True, + ) + team_games.append(team_specific_games[0]) + + for wc in self.favorite_weight_class: + # Find games where this team is playing + team_specific_games = [ + game + for game in favorite_team_games + if game["fight_class"].lower() == wc.lower() + ] + + if team_specific_games: + # Sort by game time and take the most recent + team_specific_games.sort( + key=lambda g: g.get("start_time_utc") + or datetime.min.replace(tzinfo=timezone.utc), + reverse=True, + ) + team_games.append(team_specific_games[0]) + + team_games = list(set(team_games)) + # Debug: Show which games are selected for display + for i, game in enumerate(team_games): + self.logger.info( + f"Game {i+1} for display: {game['away_abbr']} @ {game['home_abbr']} - {game.get('start_time_utc')} - Score: {game['away_score']}-{game['home_score']}" + ) + else: + team_games = ( + processed_games # Show all recent games if no favorites defined + ) + self.logger.info( + f"Found {len(processed_games)} total final games within last 21 days (no favorite teams configured)" + ) + # Sort by game time, most recent first + team_games.sort( + key=lambda g: g.get("start_time_utc") + or datetime.min.replace(tzinfo=timezone.utc), + reverse=True, + ) + # Limit to the specified number of recent games + team_games = team_games[: self.recent_games_to_show] + + # Check if the list of games to display has changed + new_game_ids = {g["id"] for g in team_games} + current_game_ids = {g["id"] for g in self.games_list} + + if new_game_ids != current_game_ids: + self.logger.info( + f"Found {len(team_games)} final games within window for display." + ) # Changed log prefix + self.games_list = team_games + # Reset index if list changed or current game removed + if ( + not self.current_game + or not self.games_list + or self.current_game["id"] not in new_game_ids + ): + self.current_game_index = 0 + self.current_game = self.games_list[0] if self.games_list else None + self.last_game_switch = current_time # Reset switch timer + else: + # Try to maintain position if possible + try: + self.current_game_index = next( + i + for i, g in enumerate(self.games_list) + if g["id"] == self.current_game["id"] + ) + self.current_game = self.games_list[ + self.current_game_index + ] # Update data just in case + except StopIteration: + self.current_game_index = 0 + self.current_game = self.games_list[0] + self.last_game_switch = current_time + + elif self.games_list: + # List content is same, just update data for current game + self.current_game = self.games_list[self.current_game_index] + + if not self.games_list: + self.logger.info( + "No relevant recent games found to display." + ) # Changed log prefix + self.current_game = None # Ensure display clears if no games + + except Exception as e: + self.logger.error( + f"Error updating recent games: {e}", exc_info=True + ) # Changed log prefix + # Don't clear current game on error, keep showing last known state + # self.current_game = None # Decide if we want to clear display on error + + +class MMAUpcoming(MMA, SportsUpcoming): + 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 _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: + """Draw the layout for a recently completed NCAA FB 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) + + fighter1_image = self._load_and_resize_logo( + game["fighter1_id"], + game["fighter1_name"], + game["fighter1_image_path"], + game["fighter1_image_url"], + ) + fighter2_image = self._load_and_resize_logo( + game["fighter2_id"], + game["fighter2_name"], + game["fighter2_image_path"], + game["fighter2_image_url"], + ) + + # fighter1_flag_image = self._load_and_resize_logo( + # game["fighter1_id"], + # game["fighter1_name"], + # game["fighter1_image_path"], + # game["fighter1_country_url"], + # ) + # fighter2_flag_image = self._load_and_resize_logo( + # game["fighter1_id"]+"_flag", + # game["fighter1_name"], + # game["fighter1_image_path"], + # game["fighter2_country_url"], + # ) + + if not fighter1_image or not fighter2_image: + self.logger.error( + f"Failed to load logos for game: {game.get('id')}" + ) # Changed log prefix + 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 + + # MLB-style logo positions + home_x = ( + self.display_width + - fighter1_image.width + + fighter1_image.width // 4 + + 2 + ) + home_y = center_y - (fighter1_image.height // 2) + main_img.paste(fighter1_image, (home_x, home_y), fighter1_image) + fighter1_name = game.get( + "fighter1_name_short", "" + ) # Use formatted period text (e.g., "Final/OT") or default "Final" + status_x = 1 + status_y = 1 + self._draw_text_with_outline( + draw_overlay, fighter1_name, (status_x, status_y), self.fonts["odds"] + ) + + away_x = -2 - fighter2_image.width // 4 + away_y = center_y - (fighter2_image.height // 2) + main_img.paste(fighter2_image, (away_x, away_y), fighter2_image) + fighter2_name = game.get( + "fighter2_name_short", "" + ) # Use formatted period text (e.g., "Final/OT") or default "Final" + status_width = draw_overlay.textlength( + fighter2_name, font=self.fonts["odds"] + ) + status_x = self.display_width - status_width - 1 + status_y = 1 + self._draw_text_with_outline( + draw_overlay, fighter2_name, (status_x, status_y), self.fonts["odds"] + ) + + # Final Scores (Centered, same position as live) + score_text = f"Some score here" + score_width = draw_overlay.textlength(score_text, font=self.fonts["score"]) + score_x = (self.display_width - score_width) // 2 + score_y = self.display_height - 14 + self._draw_text_with_outline( + draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + ) + + # "Final" text (Top center) + status_text = game.get( + "period_text", "Final" + ) # Use formatted period text (e.g., "Final/OT") or default "Final" + status_width = draw_overlay.textlength(status_text, font=self.fonts["time"]) + status_x = (self.display_width - status_width) // 2 + status_y = 1 + self._draw_text_with_outline( + draw_overlay, status_text, (status_x, status_y), self.fonts["time"] + ) + 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: + 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 + fighter1_record = game.get("fighter1_record", "") + fighter2_record = game.get("fighter2_record", "") + + record_bbox = draw_overlay.textbbox((0, 0), "0-0-0", font=record_font) + record_height = record_bbox[3] - record_bbox[1] + record_y = self.display_height - record_height + self.logger.debug( + f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}" + ) + + # Display away team info + if fighter1_record: + away_text = fighter1_record + away_record_x = 0 + 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 fighter2_record: + home_text = fighter2_record + 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 + 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, home_text, (home_record_x, record_y), record_font + ) + + self._custom_scorebug_layout(game, draw_overlay) + # Composite and display + main_img = Image.alpha_composite(main_img, overlay) + main_img = main_img.convert("RGB") + self.display_manager.image.paste(main_img, (0, 0)) + self.display_manager.update_display() # Update display here + + except Exception as e: + self.logger.error( + f"Error displaying recent game: {e}", exc_info=True + ) # Changed log prefix + + def update(self): + """Update recent games data.""" + if not self.is_enabled: + return + current_time = time.time() + if current_time - self.last_update < self.update_interval: + return + + self.last_update = current_time # Update time even if fetch fails + + try: + data = self._fetch_data() # Uses shared cache + if not data or "events" not in data: + self.logger.warning( + "No events found in shared data." + ) # Changed log prefix + if not self.games_list: + self.current_game = None # Clear display if no games were showing + return + + events = data["events"] + self.logger.info( + f"Processing {len(events)} events from shared data." + ) # Changed log prefix + + # Process games and filter for final games, date range & favorite teams + processed_games = [] + all_upcoming_games = 0 # Count all upcoming games regardless of favorites + flattened_events = [ + { + **{k: v for k, v in event.items() if k != "competitions"}, + "competitions": [comp], + } + for event in data["events"] + for comp in event.get("competitions", []) + ] + for event in flattened_events: + game = self._extract_game_details(event) + # Filter criteria: must be final AND within recent date range + if game and game["is_upcoming"]: + all_upcoming_games += 1 + if game and game["is_upcoming"]: + if self.show_favorite_teams_only and ( + len(self.favorite_fighters) > 0 + or len(self.favorite_weight_class) > 0 + ): + if ( + not ( + game["fighter1_name"].lower() in self.favorite_fighters + or game["fighter2_name"].lower() + in self.favorite_fighters + ) + and not game["fight_class"].lower() + in self.favorite_weight_class + ): + continue + else: + favorite_games_found += 1 + if self.show_odds: + self._fetch_odds(game) + processed_games.append(game) + + # Enhanced logging for debugging + 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" + ) + + if processed_games: + for game in processed_games[:3]: # Show first 3 + self.logger.info( + f" {game['fighter1_name']} vs {game['fighter2_name']} - {game['start_time_utc']}" + ) + + # if self.favorite_teams and all_upcoming_games > 0: + # self.logger.info(f"Favorite teams: {self.favorite_teams}") + # self.logger.info(f"Found {favorite_games_found} favorite team upcoming games") + team_games = processed_games # Show all upcoming if no favorites + # Sort by game time, earliest first + team_games.sort( + key=lambda g: g.get("start_time_utc") + or datetime.max.replace(tzinfo=timezone.utc) + ) + # Limit to the specified number of upcoming games + team_games = team_games[: self.upcoming_games_to_show] + # Log changes or periodically + should_log = ( + current_time - self.last_log_time >= self.log_interval + or len(team_games) != len(self.games_list) + or any( + g1["id"] != g2.get("id") + for g1, g2 in zip(self.games_list, team_games) + ) + or (not self.games_list and team_games) + ) + + # Check if the list of games to display has changed + new_game_ids = {g["id"] for g in team_games} + current_game_ids = {g["id"] for g in self.games_list} + + if new_game_ids != current_game_ids: + self.logger.info( + f"Found {len(team_games)} upcoming games within window for display." + ) # Changed log prefix + self.games_list = team_games + if ( + not self.current_game + or not self.games_list + or self.current_game["id"] not in new_game_ids + ): + self.current_game_index = 0 + self.current_game = self.games_list[0] if self.games_list else None + self.last_game_switch = current_time + else: + try: + self.current_game_index = next( + i + for i, g in enumerate(self.games_list) + if g["id"] == self.current_game["id"] + ) + self.current_game = self.games_list[self.current_game_index] + except StopIteration: + self.current_game_index = 0 + self.current_game = self.games_list[0] + self.last_game_switch = current_time + + elif self.games_list: + self.current_game = self.games_list[ + self.current_game_index + ] # Update data + + if not self.games_list: + self.logger.info( + "No relevant upcoming games found to display." + ) # Changed log prefix + self.current_game = None + + if should_log and not self.games_list: + # Log favorite teams only if no games are found and logging is needed + self.logger.debug( + f"Favorite teams: {self.favorite_teams}" + ) # Changed log prefix + self.logger.debug( + f"Total upcoming games before filtering: {len(processed_games)}" + ) # Changed log prefix + self.last_log_time = current_time + elif should_log: + self.last_log_time = current_time + + except Exception as e: + self.logger.error( + f"Error updating recent games: {e}", exc_info=True + ) # Changed log prefix + # Don't clear current game on error, keep showing last known state + # self.current_game = None # Decide if we want to clear display on error + + +class MMALive(MMA, 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 NCAA FB 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() + ) + if game.get("is_period_break"): + period_clock_text = game.get("status_text", "Period Break") + + 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"] + ) + + # Shots on Goal + if self.show_shots_on_goal: + shots_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + home_shots = str(game.get("home_shots", "0")) + away_shots = str(game.get("away_shots", "0")) + shots_text = f"{away_shots} SHOTS {home_shots}" + shots_bbox = draw_overlay.textbbox((0, 0), shots_text, font=shots_font) + shots_height = shots_bbox[3] - shots_bbox[1] + shots_y = self.display_height - shots_height - 1 + shots_width = draw_overlay.textlength(shots_text, font=shots_font) + shots_x = (self.display_width - shots_width) // 2 + self._draw_text_with_outline( + draw_overlay, shots_text, (shots_x, shots_y), shots_font + ) + + # 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 diff --git a/src/base_classes/sports.py b/src/base_classes/sports.py index e2bbbee2..5db6d776 100644 --- a/src/base_classes/sports.py +++ b/src/base_classes/sports.py @@ -37,8 +37,8 @@ def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cach self.display_height = self.display_manager.matrix.height self.sport_key = sport_key - self.sport = None - self.league = None + self.sport = "" + self.league = "" # Initialize new architecture components (will be overridden by sport-specific classes) self.sport_config = None @@ -161,6 +161,7 @@ def _load_fonts(self): fonts['status'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) # Using 4x6 for status fonts['detail'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) # Added detail font fonts['rank'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) + fonts['odds'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) logging.info("Successfully loaded fonts") # Changed log prefix except IOError: logging.warning("Fonts not found, using default PIL font.") # Changed log prefix @@ -170,6 +171,7 @@ def _load_fonts(self): fonts['status'] = ImageFont.load_default() fonts['detail'] = ImageFont.load_default() fonts['rank'] = ImageFont.load_default() + fonts['odds'] = ImageFont.load_default() return fonts def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int) -> None: @@ -320,11 +322,14 @@ def _fetch_odds(self, game: Dict) -> None: update_interval = self.mode_config.get("live_odds_update_interval", 60) if is_live \ else self.mode_config.get("odds_update_interval", 3600) + event_id = game.get("event_id","") + comp_id = game.get("comp_id","") # Fetch odds using OddsManager odds_data = self.odds_manager.get_odds( sport=self.sport, league=self.league, - event_id=game['id'], + event_id=event_id, + comp_id=comp_id, update_interval_seconds=update_interval ) @@ -454,6 +459,8 @@ def _extract_game_details_common(self, game_event: Dict) -> tuple[Dict | None, D details = { "id": game_event.get("id"), + "event_id": game_event.get("id"), + "comp_id": competition.get("id"), "game_time": game_time, "game_date": game_date, "start_time_utc": start_time_utc, diff --git a/src/display_controller.py b/src/display_controller.py index 6633c06e..b78b0ea8 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -1,8 +1,9 @@ -import time import logging import sys -from typing import Dict, Any, List -from datetime import datetime, time as time_obj +import time +from datetime import datetime +from datetime import time as time_obj +from typing import Any, Dict, List # Configure logging logging.basicConfig( @@ -12,35 +13,64 @@ stream=sys.stdout ) +from src.cache_manager import CacheManager +from src.calendar_manager import CalendarManager from src.clock import Clock -from src.weather_manager import WeatherManager -from src.display_manager import DisplayManager from src.config_manager import ConfigManager -from src.cache_manager import CacheManager -from src.stock_manager import StockManager -from src.stock_news_manager import StockNewsManager -from src.odds_ticker_manager import OddsTickerManager +from src.display_manager import DisplayManager from src.leaderboard_manager import LeaderboardManager -from src.nhl_managers import NHLLiveManager, NHLRecentManager, NHLUpcomingManager -from src.nba_managers import NBALiveManager, NBARecentManager, NBAUpcomingManager -from src.wnba_managers import WNBALiveManager, WNBARecentManager, WNBAUpcomingManager -from src.mlb_manager import MLBLiveManager, MLBRecentManager, MLBUpcomingManager from src.milb_manager import MiLBLiveManager, MiLBRecentManager, MiLBUpcomingManager -from src.soccer_managers import SoccerLiveManager, SoccerRecentManager, SoccerUpcomingManager -from src.nfl_managers import NFLLiveManager, NFLRecentManager, NFLUpcomingManager -from src.ncaa_fb_managers import NCAAFBLiveManager, NCAAFBRecentManager, NCAAFBUpcomingManager -from src.ncaa_baseball_managers import NCAABaseballLiveManager, NCAABaseballRecentManager, NCAABaseballUpcomingManager -from src.ncaam_basketball_managers import NCAAMBasketballLiveManager, NCAAMBasketballRecentManager, NCAAMBasketballUpcomingManager -from src.ncaaw_basketball_managers import NCAAWBasketballLiveManager, NCAAWBasketballRecentManager, NCAAWBasketballUpcomingManager -from src.ncaam_hockey_managers import NCAAMHockeyLiveManager, NCAAMHockeyRecentManager, NCAAMHockeyUpcomingManager -from src.ncaaw_hockey_managers import NCAAWHockeyLiveManager, NCAAWHockeyRecentManager, NCAAWHockeyUpcomingManager -from src.youtube_display import YouTubeDisplay -from src.calendar_manager import CalendarManager -from src.text_display import TextDisplay -from src.static_image_manager import StaticImageManager +from src.mlb_manager import MLBLiveManager, MLBRecentManager, MLBUpcomingManager from src.music_manager import MusicManager, SkipModuleException -from src.of_the_day_manager import OfTheDayManager +from src.nba_managers import NBALiveManager, NBARecentManager, NBAUpcomingManager +from src.ncaa_baseball_managers import ( + NCAABaseballLiveManager, + NCAABaseballRecentManager, + NCAABaseballUpcomingManager, +) +from src.ncaa_fb_managers import ( + NCAAFBLiveManager, + NCAAFBRecentManager, + NCAAFBUpcomingManager, +) +from src.ncaam_basketball_managers import ( + NCAAMBasketballLiveManager, + NCAAMBasketballRecentManager, + NCAAMBasketballUpcomingManager, +) +from src.ncaam_hockey_managers import ( + NCAAMHockeyLiveManager, + NCAAMHockeyRecentManager, + NCAAMHockeyUpcomingManager, +) +from src.ncaaw_basketball_managers import ( + NCAAWBasketballLiveManager, + NCAAWBasketballRecentManager, + NCAAWBasketballUpcomingManager, +) +from src.ncaaw_hockey_managers import ( + NCAAWHockeyLiveManager, + NCAAWHockeyRecentManager, + NCAAWHockeyUpcomingManager, +) from src.news_manager import NewsManager +from src.nfl_managers import NFLLiveManager, NFLRecentManager, NFLUpcomingManager +from src.nhl_managers import NHLLiveManager, NHLRecentManager, NHLUpcomingManager +from src.odds_ticker_manager import OddsTickerManager +from src.of_the_day_manager import OfTheDayManager +from src.soccer_managers import ( + SoccerLiveManager, + SoccerRecentManager, + SoccerUpcomingManager, +) +from src.static_image_manager import StaticImageManager +from src.stock_manager import StockManager +from src.stock_news_manager import StockNewsManager +from src.text_display import TextDisplay +from src.ufc_mananger import UFCLiveManager, UFCRecentManager, UFCUpcomingManager +from src.weather_manager import WeatherManager +from src.wnba_managers import WNBALiveManager, WNBARecentManager, WNBAUpcomingManager +from src.youtube_display import YouTubeDisplay # Get logger without configuring logger = logging.getLogger(__name__) @@ -306,6 +336,21 @@ def __init__(self): self.ncaaw_hockey_recent = None self.ncaaw_hockey_upcoming = None logger.info("NCAA Men's Hockey managers initialized in %.3f seconds", time.time() - ncaaw_hockey_time) + + # Initialize UFC managers if enabled + ufc_time = time.time() + ufc_enabled = self.config.get('ufc_scoreboard', {}).get('enabled', False) + ufc_display_modes = self.config.get('ufc_scoreboard', {}).get('display_modes', {}) + + if ufc_enabled: + self.ufc_live = UFCLiveManager(self.config, self.display_manager, self.cache_manager) if ufc_display_modes.get('ufc_live', True) else None + self.ufc_recent = UFCRecentManager(self.config, self.display_manager, self.cache_manager) if ufc_display_modes.get('ufc_recent', True) else None + self.ufc_upcoming = UFCUpcomingManager(self.config, self.display_manager, self.cache_manager) if ufc_display_modes.get('ufc_upcoming', True) else None + else: + self.ufc_live = None + self.ufc_recent = None + self.ufc_upcoming = None + logger.info("UFC managers initialized in %.3f seconds", time.time() - ufc_time) # Track MLB rotation state self.mlb_current_team_index = 0 @@ -327,6 +372,7 @@ def __init__(self): self.ncaaw_basketball_live_priority = self.config.get('ncaaw_basketball_scoreboard', {}).get('live_priority', True) self.ncaam_hockey_live_priority = self.config.get('ncaam_hockey_scoreboard', {}).get('live_priority', True) self.ncaaw_hockey_live_priority = self.config.get('ncaaw_hockey_scoreboard', {}).get('live_priority', True) + self.ufc_live_priority = self.config.get('ufc_scoreboard', {}).get('live_priority', True) self.music_live_priority = self.config.get('music', {}).get('live_priority', True) # Live priority logging throttling @@ -391,6 +437,9 @@ def __init__(self): if ncaaw_hockey_enabled: if self.ncaaw_hockey_recent: self.available_modes.append('ncaaw_hockey_recent') if self.ncaaw_hockey_upcoming: self.available_modes.append('ncaaw_hockey_upcoming') + if ufc_enabled: + if self.ufc_recent: self.available_modes.append('ufc_recent') + if self.ufc_upcoming: self.available_modes.append('ufc_upcoming') # Add live modes to rotation if live_priority is False and there are live games self._update_live_modes_in_rotation() @@ -515,9 +564,12 @@ def __init__(self): 'ncaam_hockey_live': 30, # Added NCAA Men's Hockey durations 'ncaam_hockey_recent': 15, 'ncaam_hockey_upcoming': 15, - 'ncaaw_hockey_live': 30, # Added NCAA Men's Hockey durations + 'ncaaw_hockey_live': 30, # Added NCAA Women's Hockey durations 'ncaaw_hockey_recent': 15, - 'ncaaw_hockey_upcoming': 15 + 'ncaaw_hockey_upcoming': 15, + 'ufc_live': 30, # Added UFC durations + 'ufc_recent': 15, + 'ufc_upcoming': 15 } # Merge loaded durations with defaults for key, value in default_durations.items(): @@ -799,6 +851,10 @@ def _update_modules(self): if self.ncaaw_hockey_live: self.ncaaw_hockey_live.update() if self.ncaaw_hockey_recent: self.ncaaw_hockey_recent.update() if self.ncaaw_hockey_upcoming: self.ncaaw_hockey_upcoming.update() + elif current_sport == 'ufc': + if self.ufc_live: self.ufc_live.update() + if self.ufc_recent: self.ufc_recent.update() + if self.ufc_upcoming: self.ufc_upcoming.update() else: # If no specific sport is active, update all managers (fallback behavior) # This ensures data is available when switching to a sport @@ -854,6 +910,10 @@ def _update_modules(self): if self.ncaaw_hockey_recent: self.ncaaw_hockey_recent.update() if self.ncaaw_hockey_upcoming: self.ncaaw_hockey_upcoming.update() + if self.ufc_live: self.ufc_live.update() + if self.ufc_recent: self.ufc_recent.update() + if self.ufc_upcoming: self.ufc_upcoming.update() + def _check_live_games(self) -> tuple: """ Check if there are any live games available. @@ -889,6 +949,8 @@ def _check_live_games(self) -> tuple: live_checks['ncaam_hockey'] = self.ncaam_hockey_live and self.ncaam_hockey_live.live_games if 'ncaaw_hockey_scoreboard' in self.config and self.config['ncaaw_hockey_scoreboard'].get('enabled', False): live_checks['ncaaw_hockey'] = self.ncaaw_hockey_live and self.ncaaw_hockey_live.live_games + if 'ufc_scoreboard' in self.config and self.config['ufc_scoreboard'].get('enabled', False): + live_checks['ufc'] = self.ufc_live and self.ufc_live.live_games for sport, has_live_games in live_checks.items(): if has_live_games: @@ -1105,6 +1167,7 @@ def update_mode(mode_name, manager, live_priority, sport_enabled): ncaaw_basketball_enabled = self.config.get('ncaaw_basketball_scoreboard', {}).get('enabled', False) ncaam_hockey_enabled = self.config.get('ncaam_hockey_scoreboard', {}).get('enabled', False) ncaaw_hockey_enabled = self.config.get('ncaaw_hockey_scoreboard', {}).get('enabled', False) + ufc_enabled = self.config.get('ufc_scoreboard', {}).get('enabled', False) update_mode('nhl_live', getattr(self, 'nhl_live', None), self.nhl_live_priority, nhl_enabled) update_mode('nba_live', getattr(self, 'nba_live', None), self.nba_live_priority, nba_enabled) @@ -1119,6 +1182,7 @@ def update_mode(mode_name, manager, live_priority, sport_enabled): update_mode('ncaaw_basketball_live', getattr(self, 'ncaaw_basketball_live', None), self.ncaaw_basketball_live_priority, ncaaw_basketball_enabled) update_mode('ncaam_hockey_live', getattr(self, 'ncaam_hockey_live', None), self.ncaam_hockey_live_priority, ncaam_hockey_enabled) update_mode('ncaaw_hockey_live', getattr(self, 'ncaaw_hockey_live', None), self.ncaaw_hockey_live_priority, ncaaw_hockey_enabled) + update_mode('ufc_live', getattr(self, 'ufc_live', None), self.ufc_live_priority, ufc_enabled) # Add music to live priority rotation if enabled and music is playing music_enabled = self.config.get('music', {}).get('enabled', False) # Throttle debug logging - only log every 30 seconds @@ -1206,7 +1270,8 @@ def run(self): ('ncaam_basketball', 'ncaam_basketball_live', self.ncaam_basketball_live_priority), ('ncaaw_basketball', 'ncaaw_basketball_live', self.ncaaw_basketball_live_priority), ('ncaam_hockey', 'ncaam_hockey_live', self.ncaam_hockey_live_priority), - ('ncaaw_hockey', 'ncaaw_hockey_live', self.ncaaw_hockey_live_priority) + ('ncaaw_hockey', 'ncaaw_hockey_live', self.ncaaw_hockey_live_priority), + ('ufc', 'ufc_live', self.ufc_live_priority) ]: manager = getattr(self, attr, None) @@ -1466,6 +1531,12 @@ def run(self): manager_to_display = self.milb_live elif self.current_display_mode == 'soccer_live' and self.soccer_live: manager_to_display = self.soccer_live + elif self.current_display_mode == 'ufc_live' and self.ufc_live: + manager_to_display = self.ufc_live + elif self.current_display_mode == 'ufc_recent' and self.ufc_recent: + manager_to_display = self.ufc_recent + elif self.current_display_mode == 'ufc_upcoming' and self.ufc_upcoming: + manager_to_display = self.ufc_upcoming # --- Perform Display Update --- try: @@ -1551,6 +1622,10 @@ def run(self): self.ncaaw_hockey_recent.display(force_clear=self.force_clear) elif self.current_display_mode == 'ncaaw_hockey_upcoming' and self.ncaaw_hockey_upcoming: self.ncaaw_hockey_upcoming.display(force_clear=self.force_clear) + elif self.current_display_mode == 'ufc_recent' and self.ufc_recent: + self.ufc_recent.display(force_clear=self.force_clear) + elif self.current_display_mode == 'ufc_upcoming' and self.ufc_upcoming: + self.ufc_upcoming.display(force_clear=self.force_clear) elif self.current_display_mode == 'milb_live' and self.milb_live and len(self.milb_live.live_games) > 0: logger.debug(f"[DisplayController] Calling MiLB live display with {len(self.milb_live.live_games)} live games") # Update data before displaying for live managers diff --git a/src/odds_manager.py b/src/odds_manager.py index 00fa2cd0..6fe548d9 100644 --- a/src/odds_manager.py +++ b/src/odds_manager.py @@ -1,11 +1,10 @@ -import time +import json import logging +from typing import Any, Dict, Optional + import requests -import json -from datetime import datetime, timedelta, timezone + from src.cache_manager import CacheManager -import pytz -from typing import Dict, Any, Optional, List # Import the API counter function from web interface try: @@ -22,10 +21,12 @@ def __init__(self, cache_manager: CacheManager, config_manager=None): self.logger = logging.getLogger(__name__) self.base_url = "https://sports.core.api.espn.com/v2/sports" - def get_odds(self, sport: str | None, league: str | None, event_id: str, update_interval_seconds=3600): - if sport is None or league is None: + def get_odds(self, sport: str, league: str, event_id: str, comp_id: str | None = None, update_interval_seconds=3600): + if sport is None or league is None or event_id is None: raise ValueError("Sport and League cannot be None") - cache_key = f"odds_espn_{sport}_{league}_{event_id}" + if comp_id is None: + comp_id = event_id + cache_key = f"odds_espn_{sport}_{league}_{event_id}_{comp_id}" # Check cache first cached_data = self.cache_manager.get_with_auto_strategy(cache_key) @@ -47,7 +48,7 @@ def get_odds(self, sport: str | None, league: str | None, event_id: str, update_ } espn_league = league_mapping.get(league, league) - url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds" + url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{comp_id}/odds" self.logger.info(f"Requesting odds from URL: {url}") response = requests.get(url, timeout=10) response.raise_for_status() @@ -95,12 +96,12 @@ def _extract_espn_data(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: "over_under": item.get("overUnder"), "spread": item.get("spread"), "home_team_odds": { - "money_line": item.get("homeTeamOdds", {}).get("moneyLine"), - "spread_odds": item.get("homeTeamOdds", {}).get("current", {}).get("pointSpread", {}).get("value") + "money_line": item.get("homeTeamOdds", item.get("homeAthleteOdds",{})).get("moneyLine"), + "spread_odds": item.get("homeTeamOdds", item.get("homeAthleteOdds",{})).get("current", {}).get("pointSpread", {}).get("value") }, "away_team_odds": { - "money_line": item.get("awayTeamOdds", {}).get("moneyLine"), - "spread_odds": item.get("awayTeamOdds", {}).get("current", {}).get("pointSpread", {}).get("value") + "money_line": item.get("awayTeamOdds", item.get("awayAthleteOdds",{})).get("moneyLine"), + "spread_odds": item.get("awayTeamOdds", item.get("awayAthleteOdds",{})).get("current", {}).get("pointSpread", {}).get("value") } } self.logger.debug(f"Returning extracted odds data: {json.dumps(extracted_data, indent=2)}") diff --git a/src/ufc_mananger.py b/src/ufc_mananger.py new file mode 100644 index 00000000..85dd54fe --- /dev/null +++ b/src/ufc_mananger.py @@ -0,0 +1,225 @@ +import logging +import os +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Dict, List, Optional + +import pytz +import requests + +from src.base_classes.mma import MMA, MMALive, MMARecent, MMAUpcoming +from src.cache_manager import CacheManager +from src.display_manager import DisplayManager + +# Constants +ESPN_UFC_SCOREBOARD_URL = ( + "https://site.api.espn.com/apis/site/v2/sports/mma/ufc/scoreboard" +) + + +class BaseUFCManager(MMA): # Renamed class + """Base class for UFC managers with common functionality.""" + + # Class variables for warning tracking + _no_data_warning_logged = False + _last_warning_time = 0 + _warning_cooldown = 60 # Only log warnings once per minute + _shared_data = None + _last_shared_update = 0 + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + self.logger = logging.getLogger("UFC") # Changed logger name + super().__init__( + config=config, + display_manager=display_manager, + cache_manager=cache_manager, + logger=self.logger, + sport_key="ufc", + ) + + # Check display modes to determine what data to fetch + display_modes = self.mode_config.get("display_modes", {}) + self.recent_enabled = display_modes.get("ufc_recent", False) + self.upcoming_enabled = display_modes.get("ufc_upcoming", False) + self.live_enabled = display_modes.get("ufc_live", False) + + self.logger.info( + f"Initialized UFC manager with display dimensions: {self.display_width}x{self.display_height}" + ) + 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.league = "ufc" + + def _fetch_ufc_api_data(self, use_cache: bool = True) -> Optional[Dict]: + """ + Fetches the full season schedule for UFC using background threading. + Returns cached data immediately if available, otherwise starts background fetch. + """ + now = datetime.now(pytz.utc) + season_year = now.year + datestring = f"{season_year}0101-{season_year}1231" + cache_key = f"{self.sport_key}_schedule_{season_year}" + + # Check cache first + if use_cache: + cached_data = self.cache_manager.get(cache_key) + if cached_data: + # Validate cached data structure + if isinstance(cached_data, dict) and "events" in cached_data: + 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) + + # 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="nfl", + year=season_year, + url=ESPN_UFC_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_data(self) -> Optional[Dict]: + """Fetch data using shared data mechanism or direct fetch for live.""" + if isinstance(self, UFCLiveManager): + # Live games should fetch only current games, not entire season + return self._fetch_todays_games() + else: + # Recent and Upcoming managers should use cached season data + return self._fetch_ufc_api_data(use_cache=True) + + +class UFCLiveManager(BaseUFCManager, MMALive): # Renamed class + """Manager for live NFL games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("UFCLiveManager") # Changed logger name + + if self.test_mode: + # More detailed test game for NFL + self.current_game = { + "id": "test001", + "home_abbr": "TB", + "home_id": "123", + "away_abbr": "DAL", + "away_id": "asdf", + "home_score": "21", + "away_score": "17", + "period": 4, + "period_text": "Q4", + "clock": "02:35", + "down_distance_text": "1st & 10", + "possession": "TB", # Placeholder ID for home team + "possession_indicator": "home", # Explicitly set for test + "home_timeouts": 2, + "away_timeouts": 3, + "home_logo_path": Path(self.logo_dir, "TB.png"), + "away_logo_path": Path(self.logo_dir, "DAL.png"), + "is_redzone": False, + "is_live": True, + "is_final": False, + "is_upcoming": False, + "is_halftime": False, + "status_text": "Q4 02:35", + } + self.live_games = [self.current_game] + self.logger.info("Initialized NFLLiveManager with test game: BUF vs KC") + else: + self.logger.info(" Initialized NFLLiveManager in live mode") + + +class UFCRecentManager(BaseUFCManager, MMARecent): # Renamed class + """Manager for recently completed UFC games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("UFCRecentManager") # Changed logger name + self.logger.info( + f"Initialized UFCRecentManager with {len(self.favorite_teams)} favorite teams" + ) + + +class UFCUpcomingManager(BaseUFCManager, MMAUpcoming): # Renamed class + """Manager for upcoming UFC games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("UFCUpcomingManager") # Changed logger name + self.logger.info( + f"Initialized UFCUpcomingManager with {len(self.favorite_teams)} favorite teams" + )