All checks were successful
Build Docker Image / build (pull_request) Successful in 1m14s
#40: ScorecardTracker cached data in memory at startup — background task never saw newly published scorecards. Fixed by reloading from disk on every read. #39: Win percentage defaulted to 50% when unavailable, showing a misleading 50/50 bar. Now defaults to None with "unavailable" message in embed. Parsing handles decimal (0.75), percentage string, and empty values. Also fixed orientation bug where win% was always shown as home team's even when the sheet reports the away team as the leader. Additionally: live scorebug tracker now distinguishes between "all games confirmed final" and "sheet read failures" — transient Google Sheets errors no longer hide the live scores channel. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
467 lines
20 KiB
Python
467 lines
20 KiB
Python
"""
|
|
Scorebug Service
|
|
|
|
Handles reading live game data from Google Sheets scorecards for real-time score displays.
|
|
"""
|
|
|
|
import asyncio
|
|
from typing import Dict, Any, Optional
|
|
import pygsheets
|
|
|
|
from utils.logging import get_contextual_logger
|
|
from exceptions import SheetsException
|
|
from services.sheets_service import SheetsService
|
|
|
|
|
|
class ScorebugData:
|
|
"""Data class for scorebug information."""
|
|
|
|
def __init__(self, data: Dict[str, Any]):
|
|
self.away_team_id = data.get("away_team_id", 1)
|
|
self.home_team_id = data.get("home_team_id", 1)
|
|
self.header = data.get("header", "")
|
|
self.away_score = data.get("away_score", 0)
|
|
self.home_score = data.get("home_score", 0)
|
|
self.which_half = data.get("which_half", "")
|
|
self.inning = data.get("inning", 1)
|
|
self.is_final = data.get("is_final", False)
|
|
self.outs = data.get("outs", 0)
|
|
self.win_percentage = data.get("win_percentage")
|
|
|
|
# Current matchup information
|
|
self.pitcher_name = data.get("pitcher_name", "")
|
|
self.pitcher_url = data.get("pitcher_url", "")
|
|
self.pitcher_stats = data.get("pitcher_stats", "")
|
|
self.batter_name = data.get("batter_name", "")
|
|
self.batter_url = data.get("batter_url", "")
|
|
self.batter_stats = data.get("batter_stats", "")
|
|
self.on_deck_name = data.get("on_deck_name", "")
|
|
self.in_hole_name = data.get("in_hole_name", "")
|
|
|
|
# Additional data
|
|
self.runners = data.get(
|
|
"runners", []
|
|
) # [Catcher, On First, On Second, On Third]
|
|
self.summary = data.get("summary", []) # Play-by-play summary lines
|
|
|
|
@property
|
|
def score_line(self) -> str:
|
|
"""Get formatted score line for display."""
|
|
return f"{self.away_score} @ {self.home_score}"
|
|
|
|
@property
|
|
def is_active(self) -> bool:
|
|
"""Check if game is currently active (not final)."""
|
|
return not self.is_final
|
|
|
|
@property
|
|
def current_matchup(self) -> str:
|
|
"""Get formatted current matchup string."""
|
|
if self.batter_name and self.pitcher_name:
|
|
return f"{self.batter_name} vs {self.pitcher_name}"
|
|
return ""
|
|
|
|
@property
|
|
def situation(self) -> str:
|
|
"""Get game situation (outs and runners)."""
|
|
parts = []
|
|
if self.outs is not None:
|
|
outs_text = "out" if self.outs == 1 else "outs"
|
|
parts.append(f"{self.outs} {outs_text}")
|
|
return ", ".join(parts) if parts else ""
|
|
|
|
|
|
class ScorebugService(SheetsService):
|
|
"""Google Sheets integration for reading live scorebug data."""
|
|
|
|
def __init__(self, credentials_path: Optional[str] = None):
|
|
"""
|
|
Initialize scorebug service.
|
|
|
|
Args:
|
|
credentials_path: Path to service account credentials JSON
|
|
"""
|
|
super().__init__(credentials_path)
|
|
self.logger = get_contextual_logger(f"{__name__}.ScorebugService")
|
|
|
|
async def read_scorebug_data(
|
|
self, sheet_url_or_key: str, full_length: bool = True
|
|
) -> ScorebugData:
|
|
"""
|
|
Read live scorebug data from Google Sheets scorecard.
|
|
|
|
Args:
|
|
sheet_url_or_key: Full URL or Google Sheets key
|
|
full_length: If True, includes summary data; if False, compact view
|
|
|
|
Returns:
|
|
ScorebugData object with game state
|
|
|
|
Raises:
|
|
SheetsException: If scorecard cannot be read
|
|
"""
|
|
self.logger.info(f"📖 Reading scorebug data from sheet: {sheet_url_or_key}")
|
|
self.logger.debug(f" Full length mode: {full_length}")
|
|
|
|
try:
|
|
# Open scorecard
|
|
scorecard = await self.open_scorecard(sheet_url_or_key)
|
|
self.logger.debug(f" ✅ Scorecard opened successfully")
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
# Get Scorebug tab
|
|
scorebug_tab = await loop.run_in_executor(
|
|
None, scorecard.worksheet_by_title, "Scorebug"
|
|
)
|
|
|
|
# Read all data from B2:S20 for efficiency
|
|
all_data = await loop.run_in_executor(
|
|
None,
|
|
lambda: scorebug_tab.get_values(
|
|
"B2", "S20", include_tailing_empty_rows=True
|
|
),
|
|
)
|
|
|
|
self.logger.debug(f"📊 Raw scorebug data dimensions: {len(all_data)} rows")
|
|
self.logger.debug(
|
|
f"📊 First row length: {len(all_data[0]) if all_data else 0} columns"
|
|
)
|
|
self.logger.debug(
|
|
f"📊 Reading from range B2:S20 (columns B-S = indices 0-17 in data)"
|
|
)
|
|
self.logger.debug(f"📊 Raw data structure (all rows):")
|
|
for idx, row in enumerate(all_data):
|
|
self.logger.debug(f" Row {idx} (Sheet row {idx + 2}): {row}")
|
|
|
|
# Extract game state (B2:G8)
|
|
# This corresponds to columns B-G (indices 0-5 in all_data)
|
|
# Rows 2-8 in sheet (indices 0-6 in all_data)
|
|
game_state = [
|
|
all_data[0][:6],
|
|
all_data[1][:6],
|
|
all_data[2][:6],
|
|
all_data[3][:6],
|
|
all_data[4][:6],
|
|
all_data[5][:6],
|
|
all_data[6][:6],
|
|
]
|
|
|
|
self.logger.debug(f"🎮 Extracted game_state (B2:G8):")
|
|
for idx, row in enumerate(game_state):
|
|
self.logger.debug(f" game_state[{idx}] (Sheet row {idx + 2}): {row}")
|
|
|
|
# Extract team IDs from game_state (already read from Scorebug tab)
|
|
# game_state[3] is away team row (Sheet row 5), game_state[4] is home team row (Sheet row 6)
|
|
# First column (index 0) contains the team ID - this is column B in the sheet
|
|
self.logger.debug(f"🏟️ Extracting team IDs from game_state:")
|
|
self.logger.debug(
|
|
f" Away team row: game_state[3] = Sheet row 5, column B (index 0)"
|
|
)
|
|
self.logger.debug(
|
|
f" Home team row: game_state[4] = Sheet row 6, column B (index 0)"
|
|
)
|
|
|
|
try:
|
|
away_team_id_raw = (
|
|
game_state[3][0]
|
|
if len(game_state) > 3 and len(game_state[3]) > 0
|
|
else None
|
|
)
|
|
home_team_id_raw = (
|
|
game_state[4][0]
|
|
if len(game_state) > 4 and len(game_state[4]) > 0
|
|
else None
|
|
)
|
|
|
|
self.logger.debug(f" Raw away team ID value: '{away_team_id_raw}'")
|
|
self.logger.debug(f" Raw home team ID value: '{home_team_id_raw}'")
|
|
|
|
away_team_id = int(away_team_id_raw) if away_team_id_raw else None
|
|
home_team_id = int(home_team_id_raw) if home_team_id_raw else None
|
|
|
|
self.logger.debug(
|
|
f" ✅ Parsed team IDs - Away: {away_team_id}, Home: {home_team_id}"
|
|
)
|
|
|
|
if away_team_id is None or home_team_id is None:
|
|
raise ValueError(
|
|
f"Team IDs not found in scorebug (away: {away_team_id}, home: {home_team_id})"
|
|
)
|
|
except (ValueError, IndexError) as e:
|
|
self.logger.error(f"❌ Failed to parse team IDs from scorebug: {e}")
|
|
raise ValueError(f"Could not extract team IDs from scorecard")
|
|
|
|
# Parse game state
|
|
self.logger.debug(f"📝 Parsing header from game_state[0][0] (Sheet B2):")
|
|
header = game_state[0][0] if game_state[0] else ""
|
|
is_final = header[-5:] == "FINAL" if header else False
|
|
self.logger.debug(f" Header value: '{header}'")
|
|
self.logger.debug(f" Is Final: {is_final}")
|
|
|
|
# Parse scores with validation
|
|
self.logger.debug(f"⚾ Parsing scores:")
|
|
self.logger.debug(
|
|
f" Away score: game_state[3][2] (Sheet row 5, column D)"
|
|
)
|
|
self.logger.debug(
|
|
f" Home score: game_state[4][2] (Sheet row 6, column D)"
|
|
)
|
|
|
|
try:
|
|
away_score_raw = (
|
|
game_state[3][2]
|
|
if len(game_state) > 3 and len(game_state[3]) > 2
|
|
else "0"
|
|
)
|
|
self.logger.debug(
|
|
f" Raw away score value: '{away_score_raw}' (type: {type(away_score_raw).__name__})"
|
|
)
|
|
away_score = int(away_score_raw) if away_score_raw != "" else 0
|
|
self.logger.debug(f" ✅ Parsed away score: {away_score}")
|
|
except (ValueError, IndexError) as e:
|
|
self.logger.warning(f" ⚠️ Failed to parse away score: {e}")
|
|
away_score = 0
|
|
|
|
try:
|
|
home_score_raw = (
|
|
game_state[4][2]
|
|
if len(game_state) > 4 and len(game_state[4]) > 2
|
|
else "0"
|
|
)
|
|
self.logger.debug(
|
|
f" Raw home score value: '{home_score_raw}' (type: {type(home_score_raw).__name__})"
|
|
)
|
|
home_score = int(home_score_raw) if home_score_raw != "" else 0
|
|
self.logger.debug(f" ✅ Parsed home score: {home_score}")
|
|
except (ValueError, IndexError) as e:
|
|
self.logger.warning(f" ⚠️ Failed to parse home score: {e}")
|
|
home_score = 0
|
|
|
|
try:
|
|
inning_raw = (
|
|
game_state[3][5]
|
|
if len(game_state) > 3 and len(game_state[3]) > 5
|
|
else "0"
|
|
)
|
|
self.logger.debug(
|
|
f" Raw inning value: '{inning_raw}' (type: {type(inning_raw).__name__})"
|
|
)
|
|
inning = int(inning_raw) if inning_raw != "" else 1
|
|
self.logger.debug(f" ✅ Parsed inning: {inning}")
|
|
except (ValueError, IndexError) as e:
|
|
self.logger.warning(f" ⚠️ Failed to parse home score: {e}")
|
|
inning = 1
|
|
|
|
self.logger.debug(
|
|
f"⏱️ Parsing game state from game_state[3][4] (Sheet row 5, column F):"
|
|
)
|
|
which_half = (
|
|
game_state[3][4]
|
|
if len(game_state) > 3 and len(game_state[3]) > 4
|
|
else ""
|
|
)
|
|
self.logger.debug(f" Which half value: '{which_half}'")
|
|
|
|
# Parse outs from all_data[4][4] (Sheet F6 - columns start at B, so F=index 4)
|
|
self.logger.debug(f"🔢 Parsing outs from F6 (all_data[4][4]):")
|
|
try:
|
|
outs_raw = (
|
|
all_data[4][4]
|
|
if len(all_data) > 4 and len(all_data[4]) > 4
|
|
else "0"
|
|
)
|
|
self.logger.debug(f" Raw outs value: '{outs_raw}'")
|
|
# Handle "2" or any number
|
|
outs = int(outs_raw) if outs_raw and str(outs_raw).strip() else 0
|
|
self.logger.debug(f" ✅ Parsed outs: {outs}")
|
|
except (ValueError, IndexError, AttributeError) as e:
|
|
self.logger.warning(f" ⚠️ Failed to parse outs: {e}")
|
|
outs = 0
|
|
|
|
# Extract matchup information - K3:O6 (rows 3-6, columns K-O)
|
|
# In all_data: rows 1-4 (sheet rows 3-6), columns 9-13 (sheet columns K-O)
|
|
self.logger.debug(f"⚔️ Extracting matchups from K3:O6:")
|
|
matchups = [
|
|
all_data[1][9:14] if len(all_data) > 1 else [], # Pitcher (row 3)
|
|
all_data[2][9:14] if len(all_data) > 2 else [], # Batter (row 4)
|
|
all_data[3][9:14] if len(all_data) > 3 else [], # On Deck (row 5)
|
|
all_data[4][9:14] if len(all_data) > 4 else [], # In Hole (row 6)
|
|
]
|
|
|
|
# Pitcher: matchups[0][0]=name, [1]=URL, [2]=stats
|
|
pitcher_name = matchups[0][0] if len(matchups[0]) > 0 else ""
|
|
pitcher_url = matchups[0][1] if len(matchups[0]) > 1 else ""
|
|
pitcher_stats = matchups[0][2] if len(matchups[0]) > 2 else ""
|
|
self.logger.debug(
|
|
f" Pitcher: {pitcher_name} | {pitcher_stats} | {pitcher_url}"
|
|
)
|
|
|
|
# Batter: matchups[1][0]=name, [1]=URL, [2]=stats, [3]=order, [4]=position
|
|
batter_name = matchups[1][0] if len(matchups[1]) > 0 else ""
|
|
batter_url = matchups[1][1] if len(matchups[1]) > 1 else ""
|
|
batter_stats = matchups[1][2] if len(matchups[1]) > 2 else ""
|
|
self.logger.debug(
|
|
f" Batter: {batter_name} | {batter_stats} | {batter_url}"
|
|
)
|
|
|
|
# On Deck: matchups[2][0]=name
|
|
on_deck_name = matchups[2][0] if len(matchups[2]) > 0 else ""
|
|
on_deck_url = matchups[2][1] if len(matchups[2]) > 1 else ""
|
|
self.logger.debug(f" On Deck: {on_deck_name}")
|
|
|
|
# In Hole: matchups[3][0]=name
|
|
in_hole_name = matchups[3][0] if len(matchups[3]) > 0 else ""
|
|
in_hole_url = matchups[3][1] if len(matchups[3]) > 1 else ""
|
|
self.logger.debug(f" In Hole: {in_hole_name}")
|
|
|
|
# Parse win percentage from C8 (team abbrev) and D8 (percentage)
|
|
# C8 = all_data[6][1] = winning team abbreviation
|
|
# D8 = all_data[6][2] = win probability percentage
|
|
# The sheet outputs the LEADING team's win%, so we need to
|
|
# normalize to home team's win% for the progress bar.
|
|
self.logger.debug(
|
|
f"📈 Parsing win percentage from C8:D8 (all_data[6][1:3]):"
|
|
)
|
|
try:
|
|
win_pct_team_raw = (
|
|
all_data[6][1]
|
|
if len(all_data) > 6 and len(all_data[6]) > 1
|
|
else None
|
|
)
|
|
win_pct_raw = (
|
|
all_data[6][2]
|
|
if len(all_data) > 6 and len(all_data[6]) > 2
|
|
else None
|
|
)
|
|
self.logger.debug(f" Raw win percentage team: '{win_pct_team_raw}'")
|
|
self.logger.debug(f" Raw win percentage value: '{win_pct_raw}'")
|
|
|
|
if win_pct_raw is None or str(win_pct_raw).strip() == "":
|
|
self.logger.info(
|
|
f" Win percentage unavailable (raw value: '{win_pct_raw}')"
|
|
)
|
|
win_percentage = None
|
|
else:
|
|
# Remove % sign if present and convert to float
|
|
win_pct_str = str(win_pct_raw).replace("%", "").strip()
|
|
win_percentage = float(win_pct_str)
|
|
|
|
# Handle 0.0-1.0 range (pygsheets may return decimal like 0.75)
|
|
if 0.0 <= win_percentage <= 1.0:
|
|
win_percentage = win_percentage * 100
|
|
|
|
# The sheet gives the LEADING team's win%.
|
|
# Progress bar expects HOME team's win%.
|
|
# Compare C8 abbreviation to home team abbreviation to orient correctly.
|
|
home_abbrev_raw = (
|
|
game_state[4][1]
|
|
if len(game_state) > 4 and len(game_state[4]) > 1
|
|
else ""
|
|
)
|
|
win_pct_team = (
|
|
str(win_pct_team_raw).strip() if win_pct_team_raw else ""
|
|
)
|
|
|
|
if win_pct_team and win_pct_team != home_abbrev_raw:
|
|
# The percentage belongs to the away team, flip for home perspective
|
|
self.logger.debug(
|
|
f" Win% team '{win_pct_team}' is away (home is '{home_abbrev_raw}'), "
|
|
f"flipping {win_percentage}% -> {100 - win_percentage}%"
|
|
)
|
|
win_percentage = 100 - win_percentage
|
|
|
|
self.logger.debug(
|
|
f" ✅ Parsed home win percentage: {win_percentage}%"
|
|
)
|
|
except (ValueError, IndexError, AttributeError) as e:
|
|
self.logger.info(
|
|
f" Win percentage could not be parsed (raw value: '{win_pct_raw}'): {e}"
|
|
)
|
|
win_percentage = None
|
|
|
|
self.logger.debug(f"📊 Final parsed values:")
|
|
self.logger.debug(f" Away team {away_team_id}: {away_score}")
|
|
self.logger.debug(f" Home team {home_team_id}: {home_score}")
|
|
self.logger.debug(f" Game state: '{which_half}'")
|
|
self.logger.debug(f" Outs: {outs}")
|
|
self.logger.debug(f" Win percentage: {win_percentage}%")
|
|
self.logger.debug(f" Current matchup: {batter_name} vs {pitcher_name}")
|
|
self.logger.debug(f" Status: {'FINAL' if is_final else 'IN PROGRESS'}")
|
|
|
|
# Extract runners - K11:L14 (rows 11-14, columns K-L)
|
|
# In all_data: rows 9-12 (sheet rows 11-14), columns 9-10 (sheet columns K-L)
|
|
# runners[0] = Catcher, runners[1] = On First, runners[2] = On Second, runners[3] = On Third
|
|
# Each runner is [name, URL]
|
|
self.logger.debug(f"🏃 Extracting runners from K11:L14:")
|
|
runners = [
|
|
all_data[9][9:11] if len(all_data) > 9 else [], # Catcher (row 11)
|
|
all_data[10][9:11] if len(all_data) > 10 else [], # On First (row 12)
|
|
all_data[11][9:11] if len(all_data) > 11 else [], # On Second (row 13)
|
|
all_data[12][9:11] if len(all_data) > 12 else [], # On Third (row 14)
|
|
]
|
|
self.logger.debug(f" Catcher: {runners[0]}")
|
|
self.logger.debug(f" On First: {runners[1]}")
|
|
self.logger.debug(f" On Second: {runners[2]}")
|
|
self.logger.debug(f" On Third: {runners[3]}")
|
|
|
|
# Extract summary if full_length (R3:S20 for inning-by-inning plays)
|
|
# This is the "Play by Play" summary section
|
|
summary = []
|
|
if full_length:
|
|
self.logger.debug(f"📋 Extracting summary from R3:S20:")
|
|
# R3:S20 is columns 16-17 (R-S), rows 3-20 (indices 1-18)
|
|
for row_idx in range(1, min(19, len(all_data))):
|
|
if len(all_data[row_idx]) > 17:
|
|
play_line = [all_data[row_idx][16], all_data[row_idx][17]]
|
|
if play_line[0] or play_line[1]: # Only add if not empty
|
|
summary.append(play_line)
|
|
self.logger.debug(f" Found {len(summary)} summary lines")
|
|
else:
|
|
self.logger.debug(f"📝 Skipping summary (compact view)")
|
|
|
|
self.logger.debug(f"✅ Scorebug data extraction complete!")
|
|
|
|
scorebug_data = ScorebugData(
|
|
{
|
|
"away_team_id": away_team_id,
|
|
"home_team_id": home_team_id,
|
|
"header": header,
|
|
"away_score": away_score,
|
|
"home_score": home_score,
|
|
"which_half": which_half,
|
|
"inning": inning,
|
|
"is_final": is_final,
|
|
"outs": outs,
|
|
"win_percentage": win_percentage,
|
|
"pitcher_name": pitcher_name,
|
|
"pitcher_url": pitcher_url,
|
|
"pitcher_stats": pitcher_stats,
|
|
"batter_name": batter_name,
|
|
"batter_url": batter_url,
|
|
"batter_stats": batter_stats,
|
|
"on_deck_name": on_deck_name,
|
|
"in_hole_name": in_hole_name,
|
|
"runners": runners, # [Catcher, On First, On Second, On Third], each is [name, URL]
|
|
"summary": summary, # Play-by-play lines from R3:S20
|
|
}
|
|
)
|
|
|
|
self.logger.debug(f"🎯 Created ScorebugData object:")
|
|
self.logger.debug(f" Away Team ID: {scorebug_data.away_team_id}")
|
|
self.logger.debug(f" Home Team ID: {scorebug_data.home_team_id}")
|
|
self.logger.debug(f" Header: '{scorebug_data.header}'")
|
|
self.logger.debug(f" Score Line: {scorebug_data.score_line}")
|
|
self.logger.debug(f" Which Half: '{scorebug_data.which_half}'")
|
|
self.logger.debug(f" Is Final: {scorebug_data.is_final}")
|
|
self.logger.debug(f" Is Active: {scorebug_data.is_active}")
|
|
|
|
return scorebug_data
|
|
|
|
except pygsheets.WorksheetNotFound:
|
|
self.logger.error(f"Scorebug tab not found in scorecard")
|
|
raise SheetsException("Scorebug tab not found. Is this a valid scorecard?")
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to read scorebug data: {e}")
|
|
raise SheetsException(f"Unable to read scorebug data: {str(e)}")
|