From 52af8ed05645dde4a650fb61dce6c3de62fccfeb Mon Sep 17 00:00:00 2001 From: TrustyJAID Date: Mon, 13 Nov 2023 10:23:33 -0700 Subject: [PATCH] [Hockey] Update for new API. (#328) * [Hockey] Reorganize core loops to utilize new API methods. Start building around the new API. * Get previews working. Fix goals duplicate posting. Make pickems, gdt, and gdc work on the new API system. Restructure Schedule to more easily list specific days of games. * Use GameType Enum properly. Add header to requests. Close session when cog is unloaded. * Update Standings for new API. Make schedule and games command work. These might require more work in the future. Allow api schedule commands to specify a date. --- hockey/abc.py | 2 + hockey/api.py | 906 ++++++++++++++++++++++++++++++++++++++ hockey/game.py | 448 +++++++------------ hockey/gamedaychannels.py | 14 +- hockey/gamedaythreads.py | 14 +- hockey/goal.py | 15 +- hockey/helper.py | 44 +- hockey/hockey.py | 53 ++- hockey/hockey_commands.py | 16 +- hockey/hockeypickems.py | 21 +- hockey/hockeyset.py | 16 +- hockey/pickems.py | 29 +- hockey/schedule.py | 126 +++--- hockey/standings.py | 79 +++- hockey/teamentry.py | 2 +- 15 files changed, 1313 insertions(+), 472 deletions(-) create mode 100644 hockey/api.py diff --git a/hockey/abc.py b/hockey/abc.py index ba8bf1fb90..189d13c319 100644 --- a/hockey/abc.py +++ b/hockey/abc.py @@ -8,6 +8,7 @@ from redbot.core import Config, commands from redbot.core.bot import Red +from .api import HockeyAPI from .game import Game from .helper import ( DateFinder, @@ -39,6 +40,7 @@ def __init__(self, *_args): self.session: aiohttp.ClientSession self.pickems_config: Config self._ready: asyncio.Event + self.api: HockeyAPI ####################################################################### # hockey_commands.py # diff --git a/hockey/api.py b/hockey/api.py new file mode 100644 index 0000000000..edbd0aa9e9 --- /dev/null +++ b/hockey/api.py @@ -0,0 +1,906 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from enum import Enum +from typing import Dict, List, Optional, Tuple, TypedDict + +import aiohttp +from red_commons.logging import getLogger +from redbot.core.i18n import Translator + +from .constants import TEAMS +from .game import Game, GameState, GameType +from .goal import Goal +from .standings import Standings + +TEAM_IDS = {v["id"]: k for k, v in TEAMS.items()} + +log = getLogger("red.trusty-cogs.Hockey") + +_ = Translator("Hockey", __file__) + + +ORDINALS = { + 1: _("1st"), + 2: _("2nd"), + 3: _("3rd"), + 4: _("4th"), + 5: _("5th"), +} + + +class HockeyAPIError(Exception): + pass + + +class GoalData(TypedDict): + """ + A TypedDict to contain all the needed information for Goal objects + """ + + goal_id: str + team_name: str + scorer_id: int + jersey_no: str + description: str + period: int + period_ord: str + time_remaining: str + time: datetime + home_score: int + away_score: int + strength: str + strength_code: str + empty_net: bool + event: str + link: Optional[str] + + +class GameData(TypedDict): + """ + A TypedDict to contain all the needed information for Game objects + """ + + # might not need this anymore, was in theory to prevent circular imports but I think it's not an issue + game_id: int + game_state: str + home_team: str + away_team: str + period: int + home_shots: int + away_shots: int + home_score: int + away_score: int + game_start: datetime + goals: List[GoalData] + home_goals: list + away_goals: list + home_abr: str + away_abr: str + period_ord: str + period_time_left: str + period_starts: Dict[str, datetime] + plays: List[dict] + first_star: Optional[str] + second_star: Optional[str] + third_star: Optional[str] + away_roster: Optional[dict] + home_roster: Optional[dict] + link: Optional[str] + + +class GameEventTypeCode(Enum): + UNKNOWN = 0 + FACEOFF = 502 + HIT = 503 + GIVEAWAY = 504 + GOAL = 505 + SHOT_ON_GOAL = 506 + MISSED_SHOT = 507 + BLOCKED_SHOT = 508 + PENALTY = 509 + STOPPAGE = 516 + PERIOD_START = 520 + PERIOD_END = 521 + SHOOTOUT_COMPLETE = 523 + GAME_END = 524 + TAKEAWAY = 525 + DELAYED_PENALTY = 535 + FAILED_SHOT_ATTEMPT = 537 + + +class Situation: + def __init__(self, code: str): + if len(code) != 4: + raise TypeError("Situation code must be length of 4.") + self.away_goalie = int(code[0]) + self.away_skaters = int(code[1]) + self.home_skaters = int(code[2]) + self.home_goalie = int(code[3]) + self.code = code + + def strength(self, home: bool) -> str: + """ + Get the equivalent strength from the situation code + + Parameters + ---------- + home: bool + Whether the situation represents the home team or not + """ + if self.home_skaters == self.away_skaters == 5: + return _("Even Strength") + if self.home_skaters == self.away_skaters == 4: + return _("4v4") + if self.home_skaters == self.away_skaters == 3: + return _("3v3") + if home and self.home_skaters > self.away_skaters: + return _("Power Play") + if not home and self.home_skaters > self.away_skaters: + return _("Shorthanded Goal") + + def empty_net(self, home: bool) -> str: + """ + Determine whether the situation is an empty net from the situation code + + Parameters + ---------- + home: bool + Whether the situation represents the home team or not + """ + if (home and self.away_goalie == 0) or (not home and self.home_goalie == 0): + return _("Empty Net") + return "" + + +@dataclass +class Event: + id: int + period: int + period_descriptor: dict + time_in_period: str + time_remaining: str + situation_code: str + home_team_defending_side: str + type_code: GameEventTypeCode + type_desc_key: str + sort_order: int + details: Optional[dict] + + @classmethod + def from_json(cls, data: dict) -> Event: + return cls( + id=data.get("eventId", 0), + period=data.get("period", 0), + period_descriptor=data.get("periodDescriptor", {}), + time_in_period=data.get("timeInPeriod", ""), + time_remaining=data.get("timeRemaining", ""), + situation_code=data.get("situationCode", "1551"), + home_team_defending_side=data.get("homeTeamDefendingSide", "left"), + type_code=GameEventTypeCode(data.get("typeCode", 0)), + type_desc_key=data.get("typeDescKey", ""), + sort_order=data.get("sortOrder", 0), + details=data.get("details", {}), + ) + + @property + def situation(self): + return Situation(self.situation_code) + + def get_player(self, player_id: int, data: dict) -> dict: + for player in data.get("rosterSpots", []): + if player_id == player.get("playerId"): + return player + return {} + + def description(self, data: dict) -> str: + description = "" + shot_type = "" + first_name = "" + last_name = "" + for key, value in self.details.items(): + if key == "shotType": + shot_type = value + if key == "scoringPlayerId": + player = self.get_player(value, data) + first_name = player.get("firstName", {}).get("default", "") + last_name = player.get("lastName", {}).get("default", "") + total = self.details.get("scoringPlayerTotal", 0) + description += f"{first_name} {last_name} ({total}) {shot_type}, " + + if key == "assist1PlayerId": + player = self.get_player(value, data) + first_name = player.get("firstName", {}).get("default", "") + last_name = player.get("lastName", {}).get("default", "") + total = self.details.get("assist1PlayerTotal", 0) + description += _("assists: {first_name} {last_name} ({total}), ").format( + first_name=first_name, last_name=last_name, total=total + ) + if key == "assist2PlayerId": + player = self.get_player(value, data) + first_name = player.get("firstName", {}).get("default", "") + last_name = player.get("lastName", {}).get("default", "") + total = self.details.get("assist1PlayerTotal", 0) + description += _("{first_name} {last_name} ({total})").format( + first_name=first_name, last_name=last_name, total=total + ) + + return description + + def to_goal(self, data: dict) -> Goal: + scorer_id = self.details.get("scoringPlayerId", 0) + jersey_no = self.get_player(scorer_id, data).get("sweaterNumber", 0) + + home_score = self.details.get("homeScore", 0) + away_score = self.details.get("awayScore", 0) + team_id = self.details.get("eventOwnerTeamId") + team_name = TEAM_IDS.get(team_id) + period_ord = ORDINALS.get(self.period) + home = data["homeTeam"]["id"] == team_id + return Goal( + goal_id=self.id, + team_name=team_name, + scorer_id=scorer_id, + jersey_no=jersey_no, + description=self.description(data), + period=self.period, + period_ord=period_ord, + time_remaining=self.time_remaining, + time=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + home_score=home_score, + away_score=away_score, + strength=self.situation.strength(home), + strength_code=self.situation.code, + empty_net=self.situation.empty_net(home), + event="", + link="", + ) + + +@dataclass +class ScheduledGame: + id: int + home_team: str + away_team: str + home_score: int + away_score: int + game_type: GameType + game_start: datetime + game_state: GameState + broadcasts: List[dict] + venue: str + schedule_state: str + + @classmethod + def from_statsapi(cls, data: dict) -> ScheduledGame: + raise NotImplementedError # not working so no point building this yet + + @classmethod + def from_nhle(cls, data: dict) -> ScheduledGame: + game_id = data["id"] + game_type = GameType.from_int(data["gameType"]) + venue = data["venue"].get("default", "Unknown") + broadcasts = data["tvBroadcasts"] + home_team_data = data["homeTeam"] + home_team = TEAM_IDS[home_team_data["id"]] + home_score = home_team_data.get("score", 0) + away_team_data = data["awayTeam"] + away_team = TEAM_IDS[away_team_data["id"]] + away_score = away_team_data.get("score", 0) + game_start = datetime.strptime(data["startTimeUTC"], "%Y-%m-%dT%H:%M:%SZ") + game_start = game_start.replace(tzinfo=timezone.utc) + schedule_state = data["gameScheduleState"] + period = data.get("periodDescriptor", {}).get("number", -1) + game_state = GameState.from_nhle(data["gameState"], period) + return cls( + id=game_id, + home_team=home_team, + away_team=away_team, + home_score=home_score, + away_score=away_score, + game_type=game_type, + game_start=game_start, + game_state=game_state, + broadcasts=broadcasts, + venue=venue, + schedule_state=schedule_state, + ) + + +class Schedule: + def __init__(self, days: List[List[ScheduledGame]]): + self.games: List[ScheduledGame] = [g for d in days for g in d] + self.days: List[List[ScheduledGame]] = days + + @classmethod + def from_statsapi(cls, data: dict) -> Schedule: + raise NotImplementedError + + @classmethod + def from_nhle(cls, data: dict) -> Schedule: + days = [] + for day in data.get("gameWeek", []): + games = [] + for game in day.get("games", []): + games.append(ScheduledGame.from_nhle(game)) + days.append(games) + return cls(days) + + +class HockeyAPI: + def __init__(self): + self.session = aiohttp.ClientSession( + headers={"User-Agent": "Red-DiscordBot Trusty-cogs Hockey"} + ) + self.base_url = None + + async def close(self): + await self.session.close() + + async def get_game_content(self, game_id: int): + raise NotImplementedError() + + async def get_schedule( + self, + team: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + ) -> dict: + raise NotImplementedError + + async def get_games_list( + self, + team: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + ) -> List[dict]: + raise NotImplementedError + + async def get_games( + self, + team: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + ) -> List[dict]: + raise NotImplementedError + + async def get_game_from_id(self, game_id: int) -> dict: + raise NotImplementedError + + async def get_game_from_url(self, game_url: str) -> dict: + raise NotImplementedError + + +class StatsAPI(HockeyAPI): + def __init__(self): + super().__init__() + self.base_url = "https://statsapi.web.nhl.com" + + async def get_game_content(self, game_id: int): + async with self.session.get(f"{self.base_url}/{game_id}/content") as resp: + data = await resp.json() + return data + + async def get_schedule( + self, + team: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + ) -> Schedule: + start_date_str = start_date.strftime("%Y-%m-%d") if start_date is not None else None + end_date_str = end_date.strftime("%Y-%m-%d") if end_date is not None else None + params = {"expand": "schedule.teams,schedule.linescore,schedule.broadcasts"} + url = self.base_url + "/api/v1/schedule" + if start_date is None and end_date is not None: + # if no start date is provided start with today + params["startDate"] = datetime.now().strftime("%Y-%m-%d") + params["endDate"] = end_date_str + # url = f"{BASE_URL}/api/v1/schedule?startDate={start_date_str}&endDate={end_date_str}" + elif start_date is not None and end_date is None: + # if no end date is provided carry through to the following year + params["endDate"] = str(start_date.year + 1) + start_date.strftime("-%m-%d") + params["startDate"] = start_date_str + # url = f"{BASE_URL}/api/v1/schedule?startDate={start_date_str}&endDate={end_date_str}" + if start_date_str is not None: + params["startDate"] = start_date_str + if end_date_str is not None: + params["endDate"] = end_date_str + if team not in ["all", None]: + # if a team is provided get just that TEAMS data + # url += "&teamId={}".format(TEAMS[team]["id"]) + params["teamId"] = TEAMS[team]["id"] + async with self.session.get(url, params=params) as resp: + if resp.status == 200: + data = await resp.json() + else: + data = None + log.info("Error checking schedule. %s", resp.status) + return Schedule.from_statsapi(data) + + async def get_games_list( + self, + team: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + ) -> List[dict]: + """ + Get a specified days games, defaults to the current day + requires a datetime object + returns a list of game objects + if a start date and an end date are not provided to the url + it returns only todays games + + returns a list of games + """ + data = await self.get_schedule(team, start_date, end_date) + game_list = [game for date in data["dates"] for game in date["games"]] + return game_list + + async def get_games( + self, + team: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + ) -> List[dict]: + """ + Get a specified days games, defaults to the current day + requires a datetime object + returns a list of game objects + if a start date and an end date are not provided to the url + it returns only todays games + + returns a list of game objects + """ + games_list = await self.get_games_list(team, start_date, end_date) + return_games_list = [] + if games_list != []: + for games in games_list: + try: + async with self.session.get(self.base_url + games["link"]) as resp: + data = await resp.json() + log.verbose("get_games, url: %s%s", self.base_url, games["link"]) + return_games_list.append(await self.to_game_obj_json(data)) + except Exception: + log.error("Error grabbing game data:", exc_info=True) + continue + return return_games_list + + async def get_game_from_id(self, game_id: int) -> dict: + url = f"{self.base_url}/api/v1/game/{game_id}/feed/live" + async with self.session.get(url) as resp: + data = await resp.json() + return data + + async def get_game_from_url(self, game_url: str) -> dict: + url = f"{self.base_url}/{game_url}" + async with self.session.get(url) as resp: + data = await resp.json() + return data + + def get_image_and_highlight_url( + self, event_id: int, media_content: dict + ) -> Tuple[Optional[str], ...]: + image, link = None, None + try: + if media_content["media"]["milestones"]: + for highlight in media_content["media"]["milestones"]["items"]: + if highlight["statsEventId"] == str(event_id): + for playback in highlight["highlight"]["playbacks"]: + if playback["name"] == "FLASH_1800K_896x504": + link = playback["url"] + image = ( + highlight["highlight"] + .get("image", {}) + .get("cuts", {}) + .get("1136x640", {}) + .get("src", None) + ) + else: + for highlight in media_content["highlights"]["gameCenter"]["items"]: + if "keywords" not in highlight: + continue + for keyword in highlight["keywords"]: + if keyword["type"] != "statsEventId": + continue + if keyword["value"] == str(event_id): + for playback in highlight["playbacks"]: + if playback["name"] == "FLASH_1800K_896x504": + link = playback["url"] + image = ( + highlight["image"] + .get("cuts", {}) + .get("1136x640", {}) + .get("src", None) + ) + except KeyError: + pass + return link, image + + async def to_goal(self, data: dict, players: dict, media_content: Optional[dict]) -> Goal: + scorer_id = [] + if "players" in data: + scorer_id = [ + p["player"]["id"] + for p in data["players"] + if p["playerType"] in ["Scorer", "Shooter"] + ] + + if "strength" in data["result"]: + str_dat = data["result"]["strength"]["name"] + strength_code = data["result"]["strength"]["code"] + strength = "Even Strength" if str_dat == "Even" else str_dat + if data["about"]["ordinalNum"] == "SO": + strength = "Shoot Out" + else: + strength = " " + strength_code = " " + empty_net = data["result"].get("emptyNet", False) + player_id = f"ID{scorer_id[0]}" if scorer_id != [] else None + if player_id in players: + jersey_no = players[player_id]["jerseyNumber"] + else: + jersey_no = "" + link = None + image = None + if media_content: + event_id = data["about"]["eventId"] + link, image = self.get_image_and_highlight_url(event_id, media_content) + + # scorer = scorer_id[0] + return Goal( + goal_id=data["result"]["eventCode"], + team_name=data["team"]["name"], + scorer_id=scorer_id[0] if scorer_id != [] else None, + jersey_no=jersey_no, + description=data["result"]["description"], + period=data["about"]["period"], + period_ord=data["about"]["ordinalNum"], + time_remaining=data["about"]["periodTimeRemaining"], + time=data["about"]["dateTime"], + home_score=data["about"]["goals"]["home"], + away_score=data["about"]["goals"]["away"], + strength=strength, + strength_code=strength_code, + empty_net=empty_net, + event=data["result"]["event"], + link=link, + image=image, + home_shots=data.get("home_shots", 0), + away_shots=data.get("away_shots", 0), + ) + + async def get_game_recap_from_content(self, content: dict) -> Optional[str]: + recap_url = None + for _item in ( + content.get("editorial", {"recap": {}}).get("recap", {"items": []}).get("items", []) + ): + if "playbacks" not in _item["media"]: + continue + for _playback in _item["media"]["playbacks"]: + if _playback["name"] == "FLASH_1800K_896x504": + recap_url = _playback["url"] + return recap_url + + async def to_game(self, data: dict, content: Optional[dict]) -> Game: + event = data["liveData"]["plays"]["allPlays"] + home_team = data["gameData"]["teams"]["home"]["name"] + away_team = data["gameData"]["teams"]["away"]["name"] + away_roster = data["liveData"]["boxscore"]["teams"]["away"]["players"] + home_roster = data["liveData"]["boxscore"]["teams"]["home"]["players"] + players = {} + players.update(away_roster) + players.update(home_roster) + game_id = data["gameData"]["game"]["pk"] + season = data["gameData"]["game"]["season"] + period_starts = {} + for play in data["liveData"]["plays"]["allPlays"]: + if play["result"]["eventTypeId"] == "PERIOD_START": + dt = datetime.strptime(play["about"]["dateTime"], "%Y-%m-%dT%H:%M:%SZ") + dt = dt.replace(tzinfo=timezone.utc) + period_starts[play["about"]["ordinalNum"]] = dt + + try: + recap_url = await self.get_game_recap_from_content(content) + except Exception: + log.error("Cannot get game recap url.") + recap_url = None + goals = [ + await self.to_goal(goal, players, content) + for goal in event + if goal["result"]["eventTypeId"] == "GOAL" + or ( + goal["result"]["eventTypeId"] in ["SHOT", "MISSED_SHOT"] + and goal["about"]["ordinalNum"] == "SO" + ) + ] + link = f"{self.base_url}{data['link']}" + if "currentPeriodOrdinal" in data["liveData"]["linescore"]: + period_ord = data["liveData"]["linescore"]["currentPeriodOrdinal"] + period_time_left = data["liveData"]["linescore"]["currentPeriodTimeRemaining"] + events = data["liveData"]["plays"]["allPlays"] + else: + period_ord = "0" + period_time_left = "0" + events = ["."] + decisions = data["liveData"]["decisions"] + first_star = decisions.get("firstStar", {}).get("fullName") + second_star = decisions.get("secondStar", {}).get("fullName") + third_star = decisions.get("thirdStar", {}).get("fullName") + game_type = data["gameData"]["game"]["type"] + game_state = ( + data["gameData"]["status"]["abstractGameState"] + if data["gameData"]["status"]["detailedState"] != "Postponed" + else data["gameData"]["status"]["detailedState"] + ) + return Game( + game_id=game_id, + game_state=game_state, + home_team=home_team, + away_team=away_team, + period=data["liveData"]["linescore"]["currentPeriod"], + home_shots=data["liveData"]["linescore"]["teams"]["home"]["shotsOnGoal"], + away_shots=data["liveData"]["linescore"]["teams"]["away"]["shotsOnGoal"], + home_score=data["liveData"]["linescore"]["teams"]["home"]["goals"], + away_score=data["liveData"]["linescore"]["teams"]["away"]["goals"], + game_start=data["gameData"]["datetime"]["dateTime"], + goals=goals, + home_abr=data["gameData"]["teams"]["home"]["abbreviation"], + away_abr=data["gameData"]["teams"]["away"]["abbreviation"], + period_ord=period_ord, + period_time_left=period_time_left, + period_starts=period_starts, + plays=events, + first_star=first_star, + second_star=second_star, + third_star=third_star, + away_roster=away_roster, + home_roster=home_roster, + link=link, + game_type=game_type, + season=season, + recap_url=recap_url, + # data=data, + ) + + +class NewAPI(HockeyAPI): + def __init__(self): + super().__init__() + self.base_url = "https://api-web.nhle.com/v1" + + async def get_game_content(self, game_id: int): + raise NotImplementedError() + + def team_to_abbrev(self, team: str) -> Optional[str]: + if len(team) == 3: + return team + if team.isdigit(): + team_name = TEAM_IDS[int(team)] + else: + team_name = team + return TEAMS.get(team_name, {}).get("tri_code", None) + + async def schedule_now(self) -> Schedule: + async with self.session.get(f"{self.base_url}/schedule/now") as resp: + if resp.status != 200: + log.error("Error accessing the Schedule for now. %s", resp.status) + raise HockeyAPIError("There was an error accessing the API.") + + data = await resp.json() + return Schedule.from_nhle(data) + + async def schedule(self, date: datetime) -> Schedule: + date_str = date.strftime("%Y-%m-%d") + async with self.session.get(f"{self.base_url}/schedule/{date_str}") as resp: + if resp.status != 200: + log.error("Error accessing the Schedule for now. %s", resp.status) + raise HockeyAPIError("There was an error accessing the API.") + + data = await resp.json() + return Schedule.from_nhle(data) + + async def club_schedule_season(self, team: str) -> Schedule: + team_abr = self.team_to_abbrev(team) + if team_abr is None: + raise HockeyAPIError("An unknown team name was provided") + async with self.session.get( + f"{self.base_url}/club-schedule-season/{team_abr}/now" + ) as resp: + if resp.status != 200: + log.error("Error accessing the Club Schedule for the season. %s", resp.status) + raise HockeyAPIError("There was an error accessing the API.") + + data = await resp.json() + return Schedule.from_nhle(data) + + async def club_schedule_week(self, team: str, date: Optional[datetime] = None) -> Schedule: + team_abr = self.team_to_abbrev(team) + date_str = "now" + if date is not None: + date_str = date.strftime("%Y-%M-%d") + if team_abr is None: + raise HockeyAPIError("An unknown team name was provided") + async with self.session.get( + f"{self.base_url}/club-schedule/{team_abr}/week/{date_str}" + ) as resp: + if resp.status != 200: + log.error("Error accessing the Club Schedule for the week. %s", resp.status) + raise HockeyAPIError("There was an error accessing the API.") + + data = await resp.json() + return Schedule.from_nhle(data) + + async def club_schedule_month(self, team: str, date: Optional[datetime] = None) -> Schedule: + team_abr = self.team_to_abbrev(team) + + if team_abr is None: + raise HockeyAPIError("An unknown team name was provided") + + date_str = "now" + if date is not None: + date_str = date.strftime("%Y-%M") + async with self.session.get( + f"{self.base_url}/club-schedule/{team_abr}/month/{date_str}" + ) as resp: + if resp.status != 200: + log.error("Error accessing the Club Schedule for the month. %s", resp.status) + raise HockeyAPIError("There was an error accessing the API.") + + data = await resp.json() + return Schedule.from_nhle(data) + + async def gamecenter_landing(self, game_id: int): + async with self.session.get(f"{self.base_url}/gamecenter/{game_id}/landing") as resp: + if resp.status != 200: + log.error("Error accessing the games landing page. %s", resp.status) + raise HockeyAPIError("There was an error accessing the API.") + + data = await resp.json() + return data + + async def gamecenter_pbp(self, game_id: int): + async with self.session.get(f"{self.base_url}/gamecenter/{game_id}/play-by-play") as resp: + if resp.status != 200: + log.error("Error accessing the games play-by-play. %s", resp.status) + raise HockeyAPIError("There was an error accessing the API.") + + data = await resp.json() + return data + + async def gamecenter_boxscore(self, game_id: int): + async with self.session.get(f"{self.base_url}/gamecenter/{game_id}/boxscore") as resp: + if resp.status != 200: + log.error("Error accessing the games play-by-play. %s", resp.status) + raise HockeyAPIError("There was an error accessing the API.") + + data = await resp.json() + return data + + async def standings_now(self): + async with self.session.get(f"{self.base_url}/standings/now") as resp: + if resp.status != 200: + log.error("Error accessing the standings. %s", resp.status) + raise HockeyAPIError("There was an error accessing the API.") + + data = await resp.json() + return data + + async def get_schedule( + self, + team: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + ) -> Schedule: + if team: + if start_date is not None: + return await self.club_schedule_week(team, start_date) + return await self.club_schedule_season(team) + if start_date is not None: + return await self.schedule(start_date) + return await self.schedule_now() + + async def get_standings(self) -> Standings: + data = await self.standings_now() + return Standings.from_nhle(data) + + async def get_games_list( + self, + team: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + ) -> List[Game]: + return await self.get_games(team, start_date, end_date) + + async def get_games( + self, + team: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + ) -> List[Game]: + schedule = await self.get_schedule(team, start_date, end_date) + if len(schedule.days) > 0: + return [await self.get_game_from_id(g.id) for g in schedule.days[0]] + return [] + + async def get_game_from_id(self, game_id: int) -> Game: + data = await self.gamecenter_pbp(game_id) + return await self.to_game(data) + + async def get_game_from_url(self, game_url: str) -> dict: + raise NotImplementedError + + async def to_goal(self, data: dict, players: dict, media_content: Optional[dict]) -> Goal: + # scorer = scorer_id[0] + return Goal( + goal_id=data["result"]["eventCode"], + team_name=data["team"]["name"], + scorer_id=scorer_id[0] if scorer_id != [] else None, + jersey_no=jersey_no, + description=data["result"]["description"], + period=data["about"]["period"], + period_ord=data["about"]["ordinalNum"], + time_remaining=data["about"]["periodTimeRemaining"], + time=data["about"]["dateTime"], + home_score=data["about"]["goals"]["home"], + away_score=data["about"]["goals"]["away"], + strength=strength, + strength_code=strength_code, + empty_net=empty_net, + event=data["result"]["event"], + link=link, + image=image, + home_shots=data.get("home_shots", 0), + away_shots=data.get("away_shots", 0), + ) + + async def to_game(self, data: dict, content: Optional[dict] = None) -> Game: + game_id = data["id"] + period = data.get("period", -1) + game_state = GameState.from_nhle(data["gameState"], period) + home_id = data.get("homeTeam", {}).get("id", -1) + home_team = TEAM_IDS.get(home_id, "Unknown Team") + away_id = data.get("awayTeam", {}).get("id", -1) + away_team = TEAM_IDS.get(away_id, "Unknown Team") + game_start = data["startTimeUTC"] + + period_ord = ORDINALS.get(period, "") + events = [Event.from_json(i) for i in data["plays"]] + goals = [e.to_goal(data) for e in events if e.type_code is GameEventTypeCode.GOAL] + home_roster = [p for p in data["rosterSpots"] if p["teamId"] == home_id] + away_roster = [p for p in data["rosterSpots"] if p["teamId"] == away_id] + game_type = GameType.from_int(data["gameType"]) + first_star = None + second_star = None + third_star = None + period_time_left = data.get("clock", {}).get("timeRemaining") + return Game( + game_id=game_id, + game_state=game_state, + home_team=home_team, + away_team=away_team, + period=period, + home_shots=data["homeTeam"].get("sog", 0), + away_shots=data["awayTeam"].get("sog", 0), + home_score=data["homeTeam"].get("score", 0), + away_score=data["awayTeam"].get("score", 0), + game_start=game_start, + goals=goals, + home_abr=data["homeTeam"].get("abbrev", ""), + away_abr=data["awayTeam"].get("abbrev", ""), + period_ord=period_ord, + period_time_left=period_time_left, + period_starts={}, + plays=events, + first_star=first_star, + second_star=second_star, + third_star=third_star, + away_roster=away_roster, + home_roster=home_roster, + link="", + game_type=game_type, + season=data.get("season", 0), + recap_url=None, + api=self, + # data=data, + ) diff --git a/hockey/game.py b/hockey/game.py index ee949240e5..0df26dfbd6 100644 --- a/hockey/game.py +++ b/hockey/game.py @@ -4,9 +4,8 @@ from dataclasses import dataclass from datetime import datetime, timezone from enum import Enum -from typing import Dict, List, Literal, Optional, Tuple, Union +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union -import aiohttp import discord from red_commons.logging import getLogger from redbot.core.bot import Red @@ -14,23 +13,84 @@ from redbot.core.utils import AsyncIter from redbot.core.utils.chat_formatting import pagify -from .constants import BASE_URL, CONTENT_URL, TEAMS +from .constants import TEAMS from .goal import Goal -from .helper import ( - check_to_post, - get_channel_obj, - get_team, - get_team_role, - utc_to_local, -) -from .standings import LeagueRecord, Playoffs, Standings +from .helper import check_to_post, get_channel_obj, get_team, get_team_role +from .standings import LeagueRecord, Playoffs + +if TYPE_CHECKING: + from .api import GameData _ = Translator("Hockey", __file__) log = getLogger("red.trusty-cogs.Hockey") +class GameState(Enum): + unknown = 0 + preview = 1 + preview_60 = 2 + preview_30 = 3 + preview_10 = 4 + live = 5 + live_end_first = 6 + live_end_second = 7 + live_end_third = 8 + final = 9 + + def is_preview(self): + return self in ( + GameState.preview, + GameState.preview_60, + GameState.preview_30, + GameState.preview_10, + ) + + def is_live(self): + return self in ( + GameState.live, + GameState.live_end_first, + GameState.live_end_second, + GameState.live_end_third, + ) + + @classmethod + def from_statsapi(cls, game_state: str) -> GameState: + return { + "Preview": GameState.preview, + "Preview60": GameState.preview_60, + "Preview30": GameState.preview_30, + "Preview10": GameState.preview_10, + "Live": GameState.live, + "Final": GameState.final, + }.get(game_state, GameState.unknown) + + @classmethod + def from_nhle(cls, game_state: str, period: int) -> GameState: + if period == 2: + return GameState.live_end_first + elif period == 3 and game_state == "LIVE": + return GameState.live_end_second + if period > 3 and game_state in ["LIVE", "CRIT"]: + return GameState.live_end_third + return { + "FUT": GameState.preview, + "PRE": GameState.preview, + "Preview": GameState.preview, + "Preview60": GameState.preview_60, + "Preview30": GameState.preview_30, + "Preview10": GameState.preview_10, + # These previews are only my internal code, not sure if they'll be used + "LIVE": GameState.live, + "CRIT": GameState.live, + "OVER": GameState.final, + "FINAL": GameState.final, + "OFF": GameState.final, + }.get(game_state, GameState.unknown) + + class GameType(Enum): + unknown = "Unknown" pre_season = "PR" regular_season = "R" playoffs = "P" @@ -44,6 +104,14 @@ class GameType(Enum): def __str__(self): return str(self.value) + @classmethod + def from_int(cls, value: int) -> GameType: + return { + 1: GameType.pre_season, + 2: GameType.regular_season, + 3: GameType.playoffs, + }.get(value, GameType.unknown) + @dataclass class GameStatus: @@ -170,7 +238,7 @@ class Game: """ game_id: int - game_state: str + game_state: GameState home_team: str away_team: str period: int @@ -243,11 +311,12 @@ def __init__(self, **kwargs): self.third_star = kwargs.get("third_star") self.away_roster = kwargs.get("away_roster") self.home_roster = kwargs.get("home_roster") - self.game_type: str = kwargs.get("game_type", "") + self.game_type: GameType = kwargs.get("game_type", GameType.unknown) self.link = kwargs.get("link") self.season = kwargs.get("season") self._recap_url: Optional[str] = kwargs.get("recap_url", None) self.data = kwargs.get("data", {}) + self.api = kwargs.get("api", None) def __repr__(self): return "".format( @@ -275,12 +344,16 @@ def timestamp(self) -> int: return int(self.game_start.timestamp()) def game_type_str(self): - game_types = {"PR": _("Pre Season"), "R": _("Regular Season"), "P": _("Post Season")} + game_types = { + GameType.pre_season: _("Pre Season"), + GameType.regular_season: _("Regular Season"), + GameType.playoffs: _("Post Season"), + } return game_types.get(self.game_type, _("Unknown")) def to_json(self) -> dict: return { - "game_state": self.game_state, + "game_state": self.game_state.value, "home_team": self.home_team, "away_team": self.away_team, "home_shots": self.home_shots, @@ -302,135 +375,10 @@ def to_json(self) -> dict: "first_star": self.first_star, "second_star": self.second_star, "third_star": self.third_star, - "game_type": self.game_type, + "game_type": self.game_type.value, "link": self.link, } - @staticmethod - async def get_games( - team: Optional[str] = None, - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None, - session: Optional[aiohttp.ClientSession] = None, - ) -> List[Game]: - """ - Get a specified days games, defaults to the current day - requires a datetime object - returns a list of game objects - if a start date and an end date are not provided to the url - it returns only todays games - - returns a list of game objects - """ - games_list = await Game.get_games_list(team, start_date, end_date, session) - return_games_list = [] - if games_list != []: - for games in games_list: - try: - if session is None: - async with aiohttp.ClientSession() as new_session: - async with new_session.get(BASE_URL + games["link"]) as resp: - data = await resp.json() - else: - async with session.get(BASE_URL + games["link"]) as resp: - data = await resp.json() - log.verbose("get_games, url: %s%s", BASE_URL, games["link"]) - return_games_list.append(await Game.from_json(data)) - except Exception: - log.error("Error grabbing game data:", exc_info=True) - continue - return return_games_list - - @staticmethod - async def get_game_content( - game_id: int, session: Optional[aiohttp.ClientSession] = None - ) -> dict: - data = {} - if session is None: - try: - async with aiohttp.ClientSession() as session: - async with session.get(CONTENT_URL.format(game_id)) as resp: - data = await resp.json() - except Exception: - log.exception("error pulling game content") - pass - else: - try: - async with session.get(CONTENT_URL.format(game_id)) as resp: - data = await resp.json() - except Exception: - log.exception("error pulling game content") - pass - return data - - @staticmethod - async def get_game_recap_from_content(content: dict) -> Optional[str]: - recap_url = None - for _item in ( - content.get("editorial", {"recap": {}}).get("recap", {"items": []}).get("items", []) - ): - if "playbacks" not in _item["media"]: - continue - for _playback in _item["media"]["playbacks"]: - if _playback["name"] == "FLASH_1800K_896x504": - recap_url = _playback["url"] - return recap_url - - @staticmethod - async def get_game_recap( - game_id: int, session: Optional[aiohttp.ClientSession] = None - ) -> Optional[str]: - content = await Game.get_game_content(game_id) - return await Game.get_game_recap_from_content(content) - - @staticmethod - async def get_games_list( - team: Optional[str] = None, - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None, - session: Optional[aiohttp.ClientSession] = None, - ) -> List[dict]: - """ - Get a specified days games, defaults to the current day - requires a datetime object - returns a list of game objects - if a start date and an end date are not provided to the url - it returns only todays games - - returns a list of games - """ - start_date_str = start_date.strftime("%Y-%m-%d") if start_date is not None else None - end_date_str = end_date.strftime("%Y-%m-%d") if end_date is not None else None - params = {} - url = BASE_URL + "/api/v1/schedule" - if start_date is None and end_date is not None: - # if no start date is provided start with today - params["startDate"] = datetime.now().strftime("%Y-%m-%d") - params["endDate"] = end_date_str - # url = f"{BASE_URL}/api/v1/schedule?startDate={start_date_str}&endDate={end_date_str}" - elif start_date is not None and end_date is None: - # if no end date is provided carry through to the following year - params["endDate"] = str(start_date.year + 1) + start_date.strftime("-%m-%d") - params["startDate"] = start_date_str - # url = f"{BASE_URL}/api/v1/schedule?startDate={start_date_str}&endDate={end_date_str}" - if start_date_str is not None: - params["startDate"] = start_date_str - if end_date_str is not None: - params["endDate"] = end_date_str - if team not in ["all", None]: - # if a team is provided get just that TEAMS data - # url += "&teamId={}".format(TEAMS[team]["id"]) - params["teamId"] = TEAMS[team]["id"] - if session is None: - async with aiohttp.ClientSession() as new_session: - async with new_session.get(url, params=params) as resp: - data = await resp.json() - else: - async with session.get(url, params=params) as resp: - data = await resp.json() - game_list = [game for date in data["dates"] for game in date["games"]] - return game_list - def nst_url(self): return f"https://www.naturalstattrick.com/game.php?season={self.season}&game={str(self.game_id)[5:]}&view=limited#gameflow" @@ -469,7 +417,7 @@ async def make_game_embed( ) # timestamp = datetime.strptime(self.game_start, "%Y-%m-%dT%H:%M:%SZ") title = "{away} @ {home} {state}".format( - away=self.away_team, home=self.home_team, state=self.game_state + away=self.away_team, home=self.home_team, state=self.game_state.name ) colour = ( int(TEAMS[self.home_team]["home"].replace("#", ""), 16) @@ -486,7 +434,7 @@ async def make_game_embed( text=_("{game_type} Game start ").format(game_type=self.game_type_str()), icon_url=self.away_logo, ) - if self.game_state == "Preview": + if self.game_state is GameState.preview: home_str, away_str, desc = await self.get_stats_msg() if desc is not None and em.description is None: em.description = desc @@ -503,7 +451,7 @@ async def make_game_embed( em.set_image(url=self.gameflow_url()) em.description = f"[Natural Stat Trick]({self.nst_url()})" - if self.game_state != "Preview": + if not self.game_state.is_preview(): home_msg = _("Goals: **{home_score}**\nShots: **{home_shots}**").format( home_score=self.home_score, home_shots=self.home_shots ) @@ -594,7 +542,7 @@ async def make_game_embed( if self.first_star is not None: stars = f"⭐ {self.first_star}\n⭐⭐ {self.second_star}\n⭐⭐⭐ {self.third_star}" em.add_field(name=_("Stars of the game"), value=stars, inline=False) - if self.game_state == "Live": + if self.game_state.is_live(): period = self.period_ord if self.period_time_left[0].isdigit(): msg = _("{time} Left in the {ordinal} period").format( @@ -617,11 +565,11 @@ async def game_state_embed(self) -> discord.Embed: """ # post_state = ["all", self.home_team, self.away_team] # timestamp = datetime.strptime(self.game_start, "%Y-%m-%dT%H:%M:%SZ") - title = f"{self.away_team} @ {self.home_team} {self.game_state}" + title = f"{self.away_team} @ {self.home_team} {self.game_state.name}" em = discord.Embed(timestamp=self.game_start) home_field = "{0} {1} {0}".format(self.home_emoji, self.home_team) away_field = "{0} {1} {0}".format(self.away_emoji, self.away_team) - if self.game_state != "Preview": + if not self.game_state.is_preview(): home_str = _("Goals: **{home_score}**\nShots: **{home_shots}**").format( home_score=self.home_score, home_shots=self.home_shots ) @@ -660,9 +608,9 @@ async def game_state_text(self) -> str: time_string = f"" em = ( f"{self.away_emoji}{self.away_team} @ {self.home_emoji}{self.home_team} " - f"{self.game_state}\n({time_string})" + f"{self.game_state.name}\n({time_string})" ) - if self.game_state != "Preview": + if not self.game_state.is_preview(): em = ( _("**__Current Score__**\n") + f"{self.home_emoji} {self.home_team}: {self.home_score}\n" @@ -677,12 +625,12 @@ async def get_stats_msg(self) -> Tuple[str, str, Optional[str]]: home_str = _("GP:**0** W:**0** L:**0\n**OT:**0** PTS:**0** S:**0**\n") away_str = _("GP:**0** W:**0** L:**0\n**OT:**0** PTS:**0** S:**0**\n") desc = None - if self.game_type != "P": + if self.game_type is not GameType.playoffs: msg = _( "GP:**{gp}** W:**{wins}** L:**{losses}\n**OT:**{ot}** PTS:**{pts}** S:**{streak}**\n" ) try: - standings = await Standings.get_team_standings() + standings = await self.api.get_standings() for name, record in standings.all_records.items(): if record.team.name == self.away_team: away_str = msg.format( @@ -741,34 +689,38 @@ async def get_stats_msg(self) -> Tuple[str, str, Optional[str]]: async def check_game_state(self, bot: Red, count: int = 0) -> bool: # post_state = ["all", self.home_team, self.away_team] home = await get_team(bot, self.home_team, self.game_start_str, self.game_id) + try: + old_game_state = GameState(home["game_state"]) + except ValueError: + old_game_state = GameState.unknown # away = await get_team(self.away_team) # team_list = await self.config.teams() # Home team checking - end_first = self.period_time_left == "END" and self.period == 1 - end_second = self.period_time_left == "END" and self.period == 2 - end_third = self.period_time_left == "END" and self.period == 3 - if self.game_state == "Preview": + end_first = self.period_time_left in ["END", "00:00"] and self.period == 1 + end_second = self.period_time_left in ["END", "00:00"] and self.period == 2 + end_third = self.period_time_left in ["END", "00:00"] and self.period == 3 + if self.game_state.is_preview(): """Checks if the the game state has changes from Final to Preview Could be unnecessary since after Game Final it will check for next game """ time_now = datetime.now(tz=timezone.utc) # game_time = datetime.strptime(data.game_start, "%Y-%m-%dT%H:%M:%SZ") game_start = (self.game_start - time_now).total_seconds() / 60 - if "Preview" not in home["game_state"]: + if old_game_state.value < GameState.preview.value: await self.post_game_state(bot) await self.save_game_state(bot) bot.dispatch("hockey_preview", self) - if game_start < 60 and game_start > 30 and home["game_state"] != "Preview60": + if game_start < 60 and game_start > 30 and old_game_state is not GameState.preview_60: # Post 60 minutes until game start await self.post_time_to_game_start(bot, "60") await self.save_game_state(bot, "60") bot.dispatch("hockey_preview", self) - if game_start < 30 and game_start > 10 and home["game_state"] != "Preview30": + if game_start < 30 and game_start > 10 and old_game_state is not GameState.preview_30: # Post 30 minutes until game start await self.post_time_to_game_start(bot, "30") await self.save_game_state(bot, "30") bot.dispatch("hockey_preview", self) - if game_start < 10 and game_start > 0 and home["game_state"] != "Preview10": + if game_start < 10 and game_start > 0 and old_game_state is not GameState.preview_10: # Post 10 minutes until game start await self.post_time_to_game_start(bot, "10") await self.save_game_state(bot, "10") @@ -776,10 +728,10 @@ async def check_game_state(self, bot: Red, count: int = 0) -> bool: # Create channel and look for game day thread - if self.game_state == "Live": + if self.game_state.is_live(): # Checks what the period is and posts the game is starting in the appropriate channel - if home["period"] != self.period or "Preview" in home["game_state"]: + if home["period"] != self.period or old_game_state.is_preview(): log.debug( "**%s Period starting %s at %s**", self.period_ord, @@ -793,20 +745,20 @@ async def check_game_state(self, bot: Red, count: int = 0) -> bool: if (self.home_score + self.away_score) != 0: # Check if there's goals only if there are goals await self.check_team_goals(bot) - if end_first and home["game_state"] != "LiveEND1st": + if end_first and old_game_state is not GameState.live_end_first: log.debug("End of the first period") await self.period_recap(bot, "1st") await self.save_game_state(bot, "END1st") - if end_second and home["game_state"] != "LiveEND2nd": + if end_second and old_game_state is not GameState.live_end_second: log.debug("End of the second period") await self.period_recap(bot, "2nd") await self.save_game_state(bot, "END2nd") - if end_third and home["game_state"] not in ["LiveEND3rd", "FinalEND3rd"]: + if end_third and old_game_state is not GameState.live_end_third: log.debug("End of the third period") await self.period_recap(bot, "3rd") await self.save_game_state(bot, "END3rd") - if self.game_state == "Final": + if self.game_state is GameState.final: if (self.home_score + self.away_score) != 0: # Check if there's goals only if there are goals await self.check_team_goals(bot) @@ -823,7 +775,7 @@ async def check_game_state(self, bot: Red, count: int = 0) -> bool: and len(self.away_goals) == self.away_score ) or count >= 20: """Final game state checks""" - if home["game_state"] != self.game_state and home["game_state"] != "Null": + if old_game_state is not self.game_state: # Post game final data and check for next game log.debug("Game Final %s @ %s", self.away_team, self.home_team) await self.post_game_state(bot) @@ -929,7 +881,7 @@ async def actually_post_state( publish_states = [] # await config.channel(channel).publish_states() # can_manage_webhooks = False # channel.permissions_for(guild.me).manage_webhooks - if self.game_state == "Live": + if self.game_state.is_live(): guild_notifications = guild_settings["game_state_notifications"] channel_notifications = channel_settings["game_state_notifications"] state_notifications = guild_notifications or channel_notifications @@ -944,7 +896,7 @@ async def actually_post_state( allowed_mentions = {"allowed_mentions": discord.AllowedMentions(roles=True)} else: allowed_mentions = {"allowed_mentions": discord.AllowedMentions(roles=False)} - if self.game_type == "R" and "OT" in self.period_ord: + if self.game_type is GameType.regular_season and "OT" in self.period_ord: if not guild_settings["ot_notifications"]: allowed_mentions = {"allowed_mentions": discord.AllowedMentions(roles=False)} if "SO" in self.period_ord: @@ -974,7 +926,7 @@ async def actually_post_state( log.exception("Could not post goal in %s", repr(channel)) else: - if self.game_state == "Preview": + if self.game_state.is_preview(): if game_day_channels is not None: # Don't post the preview message twice in the channel if channel.id in game_day_channels: @@ -995,7 +947,7 @@ async def actually_post_state( pass # Create new pickems object for the game - if self.game_state == "Preview": + if self.game_state.is_preview(): bot.dispatch("hockey_preview_message", channel, preview_msg, self) return channel, preview_msg except Exception: @@ -1026,7 +978,7 @@ async def check_team_goals(self, bot: Red) -> None: # goal_id = str(goal["result"]["eventCode"]) # team = goal["team"]["name"] # team_data = await get_team(bot, goal.team_name) - if goal.goal_id not in team_data[goal.team_name]["goal_id"]: + if str(goal.goal_id) not in team_data[goal.team_name]["goal_id"]: # attempts to post the goal if there is a new goal bot.dispatch("hockey_goal", self, goal) goal.home_shots = self.home_shots @@ -1040,18 +992,20 @@ async def check_team_goals(self, bot: Red) -> None: team_list.append(team_data[goal.team_name]) await bot.get_cog("Hockey").config.teams.set(team_list) continue - if goal.goal_id in team_data[goal.team_name]["goal_id"]: + if str(goal.goal_id) in team_data[goal.team_name]["goal_id"]: # attempts to edit the goal if the scorers have changed - old_goal = Goal(**team_data[goal.team_name]["goal_id"][goal.goal_id]["goal"]) + old_goal = Goal(**team_data[goal.team_name]["goal_id"][str(goal.goal_id)]["goal"]) if goal.description != old_goal.description or goal.link != old_goal.link: goal.home_shots = old_goal.home_shots goal.away_shots = old_goal.away_shots # This is to keep shots consistent between edits # Shots should not update as the game continues bot.dispatch("hockey_goal_edit", self, goal) - old_msgs = team_data[goal.team_name]["goal_id"][goal.goal_id]["messages"] + old_msgs = team_data[goal.team_name]["goal_id"][str(goal.goal_id)]["messages"] team_list.remove(team_data[goal.team_name]) - team_data[goal.team_name]["goal_id"][goal.goal_id]["goal"] = goal.to_json() + team_data[goal.team_name]["goal_id"][str(goal.goal_id)][ + "goal" + ] = goal.to_json() team_list.append(team_data[goal.team_name]) await bot.get_cog("Hockey").config.teams.set(team_list) if old_msgs: @@ -1075,33 +1029,33 @@ async def save_game_state(self, bot: Red, time_to_game_start: str = "0") -> None team_list = await bot.get_cog("Hockey").config.teams() team_list.remove(home) team_list.remove(away) - if self.game_state != "Final": - if self.game_state == "Preview" and time_to_game_start != "0": - home["game_state"] = self.game_state + time_to_game_start - away["game_state"] = self.game_state + time_to_game_start - elif self.game_state == "Live" and time_to_game_start != "0": - home["game_state"] = self.game_state + time_to_game_start - away["game_state"] = self.game_state + time_to_game_start + if self.game_state is not GameState.final: + if self.game_state.is_preview() and time_to_game_start != "0": + home["game_state"] = self.game_state.value + away["game_state"] = self.game_state.value + elif self.game_state.is_live() and time_to_game_start != "0": + home["game_state"] = self.game_state.value + away["game_state"] = self.game_state.value else: - home["game_state"] = self.game_state - away["game_state"] = self.game_state + home["game_state"] = self.game_state.value + away["game_state"] = self.game_state.value home["period"] = self.period away["period"] = self.period - home["game_start"] = self.game_start.strftime("%Y-%m-%dT%H:%M:%SZ") - away["game_start"] = self.game_start.strftime("%Y-%m-%dT%H:%M:%SZ") + home["game_start"] = self.game_start_str + away["game_start"] = self.game_start_str else: if time_to_game_start == "0": - home["game_state"] = "Null" - away["game_state"] = "Null" + home["game_state"] = 0 + away["game_state"] = 0 home["period"] = 0 away["period"] = 0 home["goal_id"] = {} away["goal_id"] = {} home["game_start"] = "" away["game_start"] = "" - elif self.game_state == "Final" and time_to_game_start != "0": - home["game_state"] = self.game_state + time_to_game_start - away["game_state"] = self.game_state + time_to_game_start + elif self.game_state is GameState.final and time_to_game_start != "0": + home["game_state"] = self.game_state.value + away["game_state"] = self.game_state.value team_list.append(home) team_list.append(away) await bot.get_cog("Hockey").config.teams.set(team_list) @@ -1142,112 +1096,6 @@ async def post_game_start(self, channel: discord.TextChannel, msg: str) -> None: log.exception("Could not post goal in %s", repr(channel)) @classmethod - async def from_gamepk( - cls, gamepk: int, session: Optional[aiohttp.ClientSession] = None - ) -> Optional[Game]: - url = f"{BASE_URL}/api/v1/game/{gamepk}/feed/live" - return await cls.from_url(url, session) - - @classmethod - async def from_url( - cls, url: str, session: Optional[aiohttp.ClientSession] = None - ) -> Optional[Game]: - url = url.replace(BASE_URL, "") # strip the base url incase we already have it - try: - if session is None: - # this should only happen in pickems objects - # since pickems don't have access to the full - # cogs session - async with aiohttp.ClientSession() as new_session: - async with new_session.get(BASE_URL + url) as resp: - data = await resp.json() - else: - async with session.get(BASE_URL + url) as resp: - data = await resp.json() - return await cls.from_json(data) - except Exception: - log.exception("Error grabbing game data: ") - return None - - @classmethod - async def from_json(cls, data: dict) -> Game: - event = data["liveData"]["plays"]["allPlays"] - home_team = data["gameData"]["teams"]["home"]["name"] - away_team = data["gameData"]["teams"]["away"]["name"] - away_roster = data["liveData"]["boxscore"]["teams"]["away"]["players"] - home_roster = data["liveData"]["boxscore"]["teams"]["home"]["players"] - players = {} - players.update(away_roster) - players.update(home_roster) - game_id = data["gameData"]["game"]["pk"] - season = data["gameData"]["game"]["season"] - period_starts = {} - for play in data["liveData"]["plays"]["allPlays"]: - if play["result"]["eventTypeId"] == "PERIOD_START": - dt = datetime.strptime(play["about"]["dateTime"], "%Y-%m-%dT%H:%M:%SZ") - dt = dt.replace(tzinfo=timezone.utc) - period_starts[play["about"]["ordinalNum"]] = dt - - content = await Game.get_game_content(game_id) - try: - recap_url = await Game.get_game_recap_from_content(content) - except Exception: - log.error("Cannot get game recap url.") - recap_url = None - goals = [ - await Goal.from_json(goal, players, content) - for goal in event - if goal["result"]["eventTypeId"] == "GOAL" - or ( - goal["result"]["eventTypeId"] in ["SHOT", "MISSED_SHOT"] - and goal["about"]["ordinalNum"] == "SO" - ) - ] - link = f"{BASE_URL}{data['link']}" - if "currentPeriodOrdinal" in data["liveData"]["linescore"]: - period_ord = data["liveData"]["linescore"]["currentPeriodOrdinal"] - period_time_left = data["liveData"]["linescore"]["currentPeriodTimeRemaining"] - events = data["liveData"]["plays"]["allPlays"] - else: - period_ord = "0" - period_time_left = "0" - events = ["."] - decisions = data["liveData"]["decisions"] - first_star = decisions.get("firstStar", {}).get("fullName") - second_star = decisions.get("secondStar", {}).get("fullName") - third_star = decisions.get("thirdStar", {}).get("fullName") - game_type = data["gameData"]["game"]["type"] - game_state = ( - data["gameData"]["status"]["abstractGameState"] - if data["gameData"]["status"]["detailedState"] != "Postponed" - else data["gameData"]["status"]["detailedState"] - ) - return cls( - game_id=game_id, - game_state=game_state, - home_team=home_team, - away_team=away_team, - period=data["liveData"]["linescore"]["currentPeriod"], - home_shots=data["liveData"]["linescore"]["teams"]["home"]["shotsOnGoal"], - away_shots=data["liveData"]["linescore"]["teams"]["away"]["shotsOnGoal"], - home_score=data["liveData"]["linescore"]["teams"]["home"]["goals"], - away_score=data["liveData"]["linescore"]["teams"]["away"]["goals"], - game_start=data["gameData"]["datetime"]["dateTime"], - goals=goals, - home_abr=data["gameData"]["teams"]["home"]["abbreviation"], - away_abr=data["gameData"]["teams"]["away"]["abbreviation"], - period_ord=period_ord, - period_time_left=period_time_left, - period_starts=period_starts, - plays=events, - first_star=first_star, - second_star=second_star, - third_star=third_star, - away_roster=away_roster, - home_roster=home_roster, - link=link, - game_type=game_type, - season=season, - recap_url=recap_url, - # data=data, - ) + def from_data(cls, data: GameData): + goals = [Goal.from_data(**i) for i in data.pop("goals", [])] + return cls(**data, goals=goals) diff --git a/hockey/gamedaychannels.py b/hockey/gamedaychannels.py index f5bdfb5522..e6dc595f44 100644 --- a/hockey/gamedaychannels.py +++ b/hockey/gamedaychannels.py @@ -275,7 +275,7 @@ async def gdc_setup( log.exception("Error accessing NHL API") return else: - game_list = await Game.get_games(session=self.session) + game_list = await self.api.get_games() for game in game_list: try: await self.create_gdc(guild, game) @@ -292,9 +292,7 @@ async def gdc_setup( ####################################################################### async def check_new_gdc(self) -> None: - game_list = await Game.get_games( - session=self.session - ) # Do this once so we don't spam the api + game_list = await self.api.get_games() # Do this once so we don't spam the api for guilds in await self.config.all_guilds(): guild = self.bot.get_guild(guilds) if guild is None: @@ -305,10 +303,10 @@ async def check_new_gdc(self) -> None: continue team = await self.config.guild(guild).gdc_team() if team != "all": - next_games = await Game.get_games_list(team, datetime.now(), session=self.session) + next_games = await self.api.get_games(team, datetime.now()) next_game = None if next_games != []: - next_game = await Game.from_url(next_games[0]["link"], session=self.session) + next_game = next_games[0] if next_game is None: continue cur_channels = await self.config.guild(guild).gdc_chans() @@ -346,9 +344,9 @@ async def create_gdc(self, guild: discord.Guild, game_data: Optional[Game] = Non if game_data is None: team = await self.config.guild(guild).gdc_team() - next_games = await Game.get_games_list(team, datetime.now(), session=self.session) + next_games = await self.api.get_games(team, datetime.now()) if next_games != []: - next_game = await Game.from_url(next_games[0]["link"], session=self.session) + next_game = next_games[0] if next_game is None: return else: diff --git a/hockey/gamedaythreads.py b/hockey/gamedaythreads.py index c407228a45..0c4a7c5e52 100644 --- a/hockey/gamedaythreads.py +++ b/hockey/gamedaythreads.py @@ -272,7 +272,7 @@ async def gdt_setup( return else: try: - game_list = await Game.get_games(session=self.session) + game_list = await self.api.get_games() except aiohttp.ClientConnectorError: await ctx.send( _("There's an issue accessing the NHL API at the moment. Try again later.") @@ -293,9 +293,7 @@ async def gdt_setup( ####################################################################### async def check_new_gdt(self) -> None: - game_list = await Game.get_games( - session=self.session - ) # Do this once so we don't spam the api + game_list = await self.api.get_games() # Do this once so we don't spam the api for guilds in await self.config.all_guilds(): guild = self.bot.get_guild(guilds) if guild is None: @@ -306,10 +304,10 @@ async def check_new_gdt(self) -> None: continue team = await self.config.guild(guild).gdt_team() if team != "all": - next_games = await Game.get_games_list(team, datetime.now(), session=self.session) + next_games = await self.api.get_games(team, datetime.now()) next_game = None if next_games != []: - next_game = await Game.from_url(next_games[0]["link"], session=self.session) + next_game = next_games[0] if next_game is None: continue cur_channel = None @@ -367,9 +365,9 @@ async def create_gdt(self, guild: discord.Guild, game_data: Optional[Game] = Non if game_data is None: team = await self.config.guild(guild).gdt_team() - next_games = await Game.get_games_list(team, datetime.now(), session=self.session) + next_games = await self.api.get_games_list(team, datetime.now()) if next_games != []: - next_game = await Game.from_url(next_games[0]["link"], session=self.session) + next_game = next_games[0] if next_game is None: return else: diff --git a/hockey/goal.py b/hockey/goal.py index 10a12f0765..44309911a2 100644 --- a/hockey/goal.py +++ b/hockey/goal.py @@ -14,6 +14,7 @@ from .helper import check_to_post, get_channel_obj, get_team if TYPE_CHECKING: + from .api import GoalData from .game import Game @@ -91,6 +92,10 @@ def to_json(self) -> dict: "away_shots": self.away_shots, } + @classmethod + def from_data(cls, data: GoalData): + return cls(**data) + @staticmethod def get_image_and_highlight_url( event_id: int, media_content: dict @@ -214,7 +219,9 @@ async def post_team_goal(self, bot: Red, game_data: Game) -> List[Tuple[int, int continue if channel.guild.me.is_timed_out(): continue - should_post = await check_to_post(bot, channel, data, post_state, "Goal") + should_post = await check_to_post( + bot, channel, data, post_state, game_data.game_state, True + ) if should_post: post_data.append( await self.actually_post_goal(bot, channel, goal_embed, goal_text) @@ -500,9 +507,9 @@ async def goal_post_embed(self, game: Game, *, include_image: bool = False) -> d url = TEAMS[self.team_name]["team_url"] if self.team_name in TEAMS else "https://nhl.com" logo = TEAMS[self.team_name]["logo"] if self.team_name in TEAMS else "https://nhl.com" if not shootout: - em = discord.Embed(description=f"{self.description}\n") + em = discord.Embed(description=f"{self.description}") if self.link: - em.description = f"[{self.description}]({self.link})\n" + em.description = f"[{self.description}]({self.link})" if colour is not None: em.colour = colour em.set_author(name=title, url=url, icon_url=logo) @@ -544,7 +551,7 @@ async def goal_post_embed(self, game: Game, *, include_image: bool = False) -> d + _(" period"), icon_url=logo, ) - em.timestamp = self.time + # em.timestamp = self.time return em async def goal_post_text(self, game: Game) -> str: diff --git a/hockey/helper.py b/hockey/helper.py index 1a2f76960c..c34a3e3bd5 100644 --- a/hockey/helper.py +++ b/hockey/helper.py @@ -537,8 +537,21 @@ async def autocomplete( return choices +def game_states_to_int(states: List[str]) -> List[int]: + ret = [] + options = {"Preview": [1, 2, 3, 4], "Live": [5], "Final": [9], "Goal": [], "Recap": [6, 7, 8]} + for state in states: + ret += options.get(state, []) + return ret + + async def check_to_post( - bot: Red, channel: discord.TextChannel, channel_data: dict, post_state: str, game_state: str + bot: Red, + channel: discord.TextChannel, + channel_data: dict, + post_state: str, + game_state: str, + is_goal: bool = False, ) -> bool: if channel is None: return False @@ -547,7 +560,12 @@ async def check_to_post( await bot.get_cog("Hockey").config.channel(channel).team.clear() return False should_post = False - if game_state in channel_data["game_states"]: + state_ints = game_states_to_int(channel_data["game_states"]) + if game_state.value in state_ints: + for team in channel_teams: + if team in post_state: + should_post = True + if is_goal and "Goal" in channel_data["game_states"]: for team in channel_teams: if team in post_state: should_post = True @@ -583,7 +601,16 @@ async def get_team(bot: Red, team: str, game_start: str, game_id: int = 0) -> di team_list = await config.teams() if team_list is None: team_list = [] - team_entry = TeamEntry("Null", team, 0, [], {}, [], "", game_id) + team_entry = TeamEntry( + game_state=0, + team_name=team, + period=0, + channel=[], + goal_id={}, + created_channel=[], + game_start=game_start, + game_id=game_id, + ) team_list.append(team_entry.to_json()) await config.teams.set(team_list) for teams in team_list: @@ -594,7 +621,16 @@ async def get_team(bot: Red, team: str, game_start: str, game_id: int = 0) -> di ): return teams # Add unknown teams to the config to track stats - return_team = TeamEntry("Null", team, 0, [], {}, [], "", game_id) + return_team = TeamEntry( + game_state=0, + team_name=team, + period=0, + channel=[], + goal_id={}, + created_channel=[], + game_start=game_start, + game_id=game_id, + ) team_list.append(return_team.to_json()) await config.teams.set(team_list) return return_team.to_json() diff --git a/hockey/hockey.py b/hockey/hockey.py index 716870f335..acf79c801c 100644 --- a/hockey/hockey.py +++ b/hockey/hockey.py @@ -13,10 +13,10 @@ from redbot.core.i18n import Translator, cog_i18n from redbot.core.utils import AsyncIter +from .api import GameState, NewAPI, Schedule from .constants import BASE_URL, CONFIG_ID, CONTENT_URL, HEADSHOT_URL, TEAMS from .dev import HockeyDev from .errors import InvalidFileError -from .game import Game from .gamedaychannels import GameDayChannels from .gamedaythreads import GameDayThreads from .helper import utc_to_local @@ -25,7 +25,6 @@ from .hockeyset import HockeySetCommands from .pickems import Pickems from .standings import Standings -from .teamentry import TeamEntry _ = Translator("Hockey", __file__) @@ -155,6 +154,7 @@ def __init__(self, bot): # data from the wrong file location self._repo = "" self._commit = "" + self.api = NewAPI() def format_help_for_context(self, ctx: commands.Context) -> str: """ @@ -179,6 +179,7 @@ async def cog_unload(self): if self.loop is not None: self.loop.cancel() await self.session.close() + await self.api.close() self.pickems_loop.cancel() await self.after_pickems_loop() @@ -318,20 +319,16 @@ async def game_check_loop(self) -> None: await self._ready.wait() while True: try: - params = {"expand": "schedule.teams,schedule.linescore,schedule.broadcasts"} - async with self.session.get(f"{BASE_URL}/api/v1/schedule") as resp: - if resp.status == 200: - data = await resp.json() - else: - log.info("Error checking schedule. %s", resp.status) - await asyncio.sleep(30) - continue + schedule = await self.api.get_schedule() + if schedule.days == []: + await asyncio.sleep(30) + continue except aiohttp.client_exceptions.ClientConnectorError: # this will most likely happen if there's a temporary failure in name resolution # this ends up calling the check_new_day earlier than expected causing # game day channels and pickems to fail to update prpoperly # continue after waiting 30 seconds should prevent that. - data = {"dates": []} + schedule = Schedule([]) await asyncio.sleep(30) continue except Exception: @@ -339,13 +336,13 @@ async def game_check_loop(self) -> None: data = {"dates": []} await asyncio.sleep(60) continue - if data["dates"] != []: - for game in data["dates"][0]["games"]: - if game["status"]["abstractGameState"] == "Final": + if schedule.days != []: + for game in schedule.days[0]: + if game.game_state is GameState.final: continue - if game["status"]["detailedState"] == "Postponed": + if game.schedule_state != "OK": continue - self.current_games[game["link"]] = { + self.current_games[game.id] = { "count": 0, "game": None, "disabled_buttons": False, @@ -357,7 +354,7 @@ async def game_check_loop(self) -> None: await self.check_new_day() if self.TEST_LOOP: self.current_games = { - "https://statsapi.web.nhl.com/api/v1/game/2020020474/feed/live": { + 2020020474: { "count": 0, "game": None, "disabled_buttons": False, @@ -366,7 +363,7 @@ async def game_check_loop(self) -> None: while self.current_games != {}: self.games_playing = True to_delete = [] - for link, data in self.current_games.items(): + for game_id, data in self.current_games.items(): if data["game"] is not None: await self.fix_pickem_game_start(data["game"]) if data["game"] is not None and data["game"].game_start - timedelta( @@ -378,30 +375,30 @@ async def game_check_loop(self) -> None: data["game"].home_team, ) continue - data = await self.get_game_data(link) + data = await self.api.get_game_from_id(game_id) if data is None: continue try: - game = await Game.from_json(data) - self.current_games[link]["game"] = game + game = await self.api.get_game_from_id(game_id) + self.current_games[game_id]["game"] = game except Exception: log.exception("Error creating game object from json.") continue try: await self.check_new_day() posted_final = await game.check_game_state( - self.bot, self.current_games[link]["count"] + self.bot, self.current_games[game_id]["count"] ) except Exception: log.exception("Error checking game state: ") posted_final = False if ( - game.game_state in ["Live"] - and not self.current_games[link]["disabled_buttons"] + game.game_state in [GameState.live] + and not self.current_games[game_id]["disabled_buttons"] ): log.verbose("Disabling buttons for %r", game) await self.disable_pickems_buttons(game) - self.current_games[link]["disabled_buttons"] = True + self.current_games[game_id]["disabled_buttons"] = True log.trace( "%s @ %s %s %s - %s", @@ -412,14 +409,14 @@ async def game_check_loop(self) -> None: game.home_score, ) - if game.game_state in ["Final", "Postponed"]: - self.current_games[link]["count"] += 1 + if game.game_state is GameState.final: + self.current_games[game_id]["count"] += 1 if posted_final: try: await self.set_guild_pickem_winner(game, edit_message=True) except Exception: log.exception("Pickems Set Winner error: ") - self.current_games[link]["count"] = 21 + self.current_games[game_id]["count"] = 21 await asyncio.sleep(1) for link in self.current_games: diff --git a/hockey/hockey_commands.py b/hockey/hockey_commands.py index 0ed4a41086..df3885182b 100644 --- a/hockey/hockey_commands.py +++ b/hockey/hockey_commands.py @@ -25,7 +25,7 @@ from .menu import BaseMenu, GamesMenu, LeaderboardPages, PlayerPages, SimplePages from .player import SimplePlayer from .schedule import Schedule, ScheduleList -from .standings import PlayoffsView, Standings, StandingsMenu +from .standings import PlayoffsView, StandingsMenu from .stats import LeaderCategories, LeaderView _ = Translator("Hockey", __file__) @@ -128,7 +128,7 @@ async def standings(self, ctx: commands.Context, *, search: StandingsFinder = No """ await ctx.defer() try: - standings = await Standings.get_team_standings(session=self.session) + standings = await self.api.get_standings() except aiohttp.ClientConnectorError: await ctx.send( _("There's an issue accessing the NHL API at the moment. Try again later.") @@ -165,7 +165,7 @@ async def games( teams = [team] try: await GamesMenu( - source=Schedule(team=teams, date=date, session=self.session), + source=Schedule(team=teams, date=date, api=self.api), cog=self, delete_message_after=False, clear_reactions_after=True, @@ -216,7 +216,7 @@ async def playoffs( season_str = int(season.group(1)) - 1 try: await PlayoffsView(start_date=season_str).start(ctx=ctx) - except aiohttp.ClientConnectorError: + except Exception: await ctx.send( _("There's an issue accessing the NHL API at the moment. Try again later.") ) @@ -260,10 +260,10 @@ async def heatmap( source=Schedule( team=teams, date=date, - session=self.session, include_goals=False, include_heatmap=True, style=style, + api=self.api, ), cog=self, delete_message_after=False, @@ -316,11 +316,11 @@ async def gameflow( source=Schedule( team=teams, date=date, - session=self.session, include_goals=False, include_gameflow=True, corsi=corsi, strength=strength, + api=self.api, ), cog=self, delete_message_after=False, @@ -359,7 +359,7 @@ async def schedule( teams = [team] try: await GamesMenu( - source=ScheduleList(team=teams, date=date, session=self.session), + source=ScheduleList(team=teams, date=date, api=self.api), cog=self, delete_message_after=False, clear_reactions_after=True, @@ -397,7 +397,7 @@ async def recap( teams = [team] try: await GamesMenu( - source=ScheduleList(team=teams, date=date, session=self.session, get_recap=True), + source=ScheduleList(team=teams, date=date, get_recap=True, api=self.api), cog=self, delete_message_after=False, clear_reactions_after=True, diff --git a/hockey/hockeypickems.py b/hockey/hockeypickems.py index 0ae714352f..01ca32464f 100644 --- a/hockey/hockeypickems.py +++ b/hockey/hockeypickems.py @@ -13,7 +13,7 @@ from hockey.helper import utc_to_local from .abc import HockeyMixin -from .game import Game +from .game import Game, GameType from .pickems import Pickems _ = Translator("Hockey", __file__) @@ -91,7 +91,7 @@ async def save_pickems_data(self) -> None: log.trace("Saving pickem %r", pickem) data[name] = pickem.to_json() self.all_pickems[guild_id][name]._should_save = False - if pickem.game_type in ["P", "PR"]: + if pickem.game_type in [GameType.pre_season, GameType.playoffs]: if (datetime.now(timezone.utc) - pickem.game_start) >= timedelta(days=7): del data[name] if guild_id not in to_del: @@ -541,7 +541,7 @@ async def create_pickems_channels_and_message( else: save_data[new_channel.guild.id].append(new_channel.id) - games_list = await Game.get_games(None, day, day, self.session) + games_list = await self.api.get_games(None, day, day) # msg_tasks = [] for game in games_list: @@ -711,7 +711,7 @@ async def tally_guild_leaderboard(self, guild: discord.Guild) -> None: async for name, pickems in AsyncIter(pickems_list.items(), steps=10): # check for definitive winner here just incase if name not in self.pickems_games: - game = await pickems.get_game() + game = await pickems.get_game(self.api) self.pickems_games[name] = game await self.set_guild_pickem_winner(self.pickems_games[name]) # Go through all the current pickems for every server @@ -748,11 +748,11 @@ async def tally_guild_leaderboard(self, guild: discord.Guild) -> None: await bank.deposit_credits(member, int(base_credits)) except Exception: log.debug("Could not deposit pickems credits for %r", member) - if pickems.game_type == "P": + if pickems.game_type is GameType.playoffs: leaderboard[str(user)]["playoffs"] += 1 leaderboard[str(user)]["playoffs_weekly"] += 1 leaderboard[str(user)]["playoffs_total"] += 1 - elif pickems.game_type == "PR": + elif pickems.game_type is GameType.pre_season: leaderboard[str(user)]["pre-season"] += 1 leaderboard[str(user)]["pre-season_weekly"] += 1 leaderboard[str(user)]["pre-season_total"] += 1 @@ -763,9 +763,9 @@ async def tally_guild_leaderboard(self, guild: discord.Guild) -> None: # playoffs is finished leaderboard[str(user)]["weekly"] += 1 else: - if pickems.game_type == "P": + if pickems.game_type is GameType.playoffs: leaderboard[str(user)]["playoffs_total"] += 1 - elif pickems.game_type == "PR": + elif pickems.game_type is GameType.pre_season: leaderboard[str(user)]["pre-season_total"] += 1 else: leaderboard[str(user)]["total"] += 1 @@ -1122,17 +1122,18 @@ async def pickems_page(self, ctx, date: Optional[str] = None) -> None: if not await self.check_pickems_req(ctx): return if date is None: - new_date = datetime.now() + new_date = datetime.now(timezone.utc) else: try: new_date = datetime.strptime(date, "%Y-%m-%d") + new_date.replace(tzinfo=timezone.utc) except ValueError: msg = _("`date` must be in the format `YYYY-MM-DD`.") await ctx.send(msg) return guild_message = await self.pickems_config.guild(ctx.guild).pickems_message() msg = _(PICKEMS_MESSAGE).format(guild_message=guild_message) - games_list = await Game.get_games(None, new_date, new_date, session=self.session) + games_list = await self.api.get_games(None, new_date, new_date) for page in pagify(msg): await ctx.channel.send(page) for game in games_list: diff --git a/hockey/hockeyset.py b/hockey/hockeyset.py index 3e80ed2efa..17bc8b0715 100644 --- a/hockey/hockeyset.py +++ b/hockey/hockeyset.py @@ -13,7 +13,7 @@ from redbot.core.utils.chat_formatting import humanize_list from .abc import HockeyMixin -from .constants import BASE_URL, TEAMS +from .constants import TEAMS from .helper import StandingsFinder, StateFinder, TeamFinder from .standings import Conferences, Divisions, Standings @@ -137,20 +137,12 @@ async def set_team_events(self, ctx: commands.Context, team: TeamFinder): This command can take a while to complete. """ - url = f"{BASE_URL}/api/v1/schedule" start = datetime.now() end = start + timedelta(days=350) - params = { - "startDate": start.strftime("%Y-%m-%d"), - "endDate": end.strftime("%Y-%m-%d"), - "expand": "schedule.teams,schedule.linescore,schedule.broadcasts", - } - if team not in ["all", None]: - # if a team is provided get just that TEAMS data - params["teamId"] = ",".join(str(TEAMS[t]["id"]) for t in [team]) try: - async with self.session.get(url, params=params) as resp: - data = await resp.json() + data = await self.api.get_schedule( + team, start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d") + ) except aiohttp.ClientConnectorError: await ctx.send( _("There's an issue accessing the NHL API at the moment. Try again later.") diff --git a/hockey/pickems.py b/hockey/pickems.py index 8da484da32..a2c2f85dad 100644 --- a/hockey/pickems.py +++ b/hockey/pickems.py @@ -9,7 +9,7 @@ from .constants import TEAMS from .errors import NotAValidTeamError, UserHasVotedError, VotingHasEndedError -from .game import Game +from .game import Game, GameState, GameType _ = Translator("Hockey", __file__) log = getLogger("red.trusty-cogs.Hockey") @@ -103,7 +103,7 @@ class Pickems(discord.ui.View): def __init__( self, game_id: int, - game_state: str, + game_state: GameState, messages: List[str], guild: int, game_start: datetime, @@ -113,7 +113,7 @@ def __init__( name: str, winner: Optional[str], link: Optional[str], - game_type: str, + game_type: GameType, should_edit: bool, ): self.game_id = game_id @@ -222,7 +222,7 @@ def add_vote(self, user_id: int, team: discord.Emoji) -> None: def to_json(self) -> Dict[str, Any]: return { "game_id": self.game_id, - "game_state": self.game_state, + "game_state": self.game_state.value, "messages": self.messages, "guild": self.guild, "game_start": self.game_start.strftime("%Y-%m-%dT%H:%M:%SZ"), @@ -232,7 +232,7 @@ def to_json(self) -> Dict[str, Any]: "name": self.name, "winner": self.winner, "link": self.link, - "game_type": self.game_type, + "game_type": self.game_type.value, "should_edit": self.should_edit, } @@ -243,7 +243,7 @@ def from_json(cls, data: dict) -> Pickems: game_start = game_start.replace(tzinfo=timezone.utc) return cls( game_id=data["game_id"], - game_state=data["game_state"], + game_state=GameState(data["game_state"]), messages=data.get("messages", []), guild=data["guild"], game_start=game_start, @@ -253,7 +253,7 @@ def from_json(cls, data: dict) -> Pickems: name=data.get("name", ""), winner=data.get("winner", None), link=data.get("link", None), - game_type=data.get("game_type", "R"), + game_type=GameType(data.get("game_type", "R")), should_edit=data.get("should_edit", True), ) @@ -284,11 +284,8 @@ async def set_pickem_winner(self, game: Game) -> bool: return True return False - async def get_game(self) -> Optional[Game]: - if self.link is not None: - return await Game.from_url(self.link) - url = f"https://statsapi.web.nhl.com/api/v1/game/{self.game_id}/feed/live" - return await Game.from_url(url) + async def get_game(self, api) -> Optional[Game]: + return await api.get_game_from_id(self.game_id) async def check_winner(self, game: Optional[Game] = None) -> bool: """ @@ -304,8 +301,8 @@ async def check_winner(self, game: Optional[Game] = None) -> bool: return True if game is not None: return await self.set_pickem_winner(game) - if self.link and after_game: - log.debug("Checking winner for %r", self) - game = await Game.from_url(self.link) - return await self.set_pickem_winner(game) + # if self.link and after_game: + # log.debug("Checking winner for %r", self) + # game = await Game.from_url(self.link) + # return await self.set_pickem_winner(game) return False diff --git a/hockey/schedule.py b/hockey/schedule.py index a0a9f928c2..166aa77f44 100644 --- a/hockey/schedule.py +++ b/hockey/schedule.py @@ -1,16 +1,16 @@ from datetime import datetime, timedelta, timezone from typing import List, Optional -import aiohttp import discord from red_commons.logging import getLogger from redbot.core.i18n import Translator from redbot.core.utils.chat_formatting import humanize_list, pagify from redbot.vendored.discord.ext import menus -from .constants import BASE_URL, TEAMS +from .api import ScheduledGame +from .constants import TEAMS from .errors import NoSchedule -from .game import Game +from .game import GameState from .helper import utc_to_local _ = Translator("Hockey", __file__) @@ -30,7 +30,6 @@ def __init__(self, **kwargs): self.limit: int = kwargs.get("limit", 10) self.team: List[str] = kwargs.get("team", []) self._last_searched: str = "" - self._session: aiohttp.ClientSession = kwargs.get("session") self.select_options = [] self.search_range = 30 self.include_heatmap = kwargs.get("include_heatmap", False) @@ -44,6 +43,7 @@ def __init__(self, **kwargs): self.vs = False if kwargs.get("vs", False) and len(self.team) == 2: self.vs = True + self.api = kwargs["api"] @property def index(self) -> int: @@ -66,7 +66,7 @@ async def get_page( ) if game_id is not None: for game in self._cache: - if game["gamePk"] == game_id: + if game.id == game_id: log.verbose("getting game %s", game_id) page_number = self._cache.index(game) log.verbose( @@ -94,16 +94,10 @@ async def get_page( self._last_page = page_number return page - async def format_page(self, menu: menus.MenuPages, game: dict) -> discord.Embed: - log.trace("%s%s", BASE_URL, game["link"]) - if self._session is not None: - async with aiohttp.ClientSession() as session: - async with session.get(BASE_URL + game["link"]) as resp: - data = await resp.json() - else: - async with self._session.get(BASE_URL + game["link"]) as resp: - data = await resp.json() - game_obj = await Game.from_json(data) + async def format_page(self, menu: menus.MenuPages, game: ScheduledGame) -> discord.Embed: + log.trace("%s/gamecenter/%s/play-by-play", self.api.base_url, game.id) + + game_obj = await self.api.get_game_from_id(game.id) # return {"content": f"{self.index+1}/{len(self._cache)}", "embed": await game_obj.make_game_embed()} em = await game_obj.make_game_embed( include_plays=self.include_plays, @@ -116,7 +110,7 @@ async def format_page(self, menu: menus.MenuPages, game: dict) -> discord.Embed: em.set_image(url=game_obj.gameflow_url(corsi=self.corsi, strength=self.strength)) em.description = f"[Natural Stat Trick]({game_obj.nst_url()})" if self.show_broadcasts: - broadcasts = game.get("broadcasts", []) + broadcasts = game.broadcasts broadcast_str = humanize_list([b["name"] for b in broadcasts]) em.add_field(name=_("Broadcasts"), value=broadcast_str) return em @@ -197,48 +191,36 @@ async def _next_batch( # log.debug("Filling the cache") # compare_date = datetime.utcnow().strftime("%Y-%m-%d") if date: - date_str = date.strftime("%Y-%m-%d") date_timestamp = int(utc_to_local(date, "UTC").timestamp()) - end_date_str = (date + timedelta(days=self.search_range)).strftime("%Y-%m-%d") end_date_timestamp = int( utc_to_local((date + timedelta(days=self.search_range)), "UTC").timestamp() ) else: - date_str = self.date.strftime("%Y-%m-%d") date_timestamp = int(utc_to_local(date, "UTC").timestamp()) - end_date_str = (self.date + timedelta(days=self.search_range)).strftime("%Y-%m-%d") end_date_timestamp = int( utc_to_local((self.date + timedelta(days=self.search_range)), "UTC").timestamp() ) - - url = f"{BASE_URL}/api/v1/schedule" - params = { - "startDate": date_str, - "endDate": end_date_str, - "expand": "schedule.teams,schedule.linescore,schedule.broadcasts", - } - if self.team not in ["all", None]: - # if a team is provided get just that TEAMS data - params["teamId"] = ",".join(str(TEAMS[t]["id"]) for t in self.team) # log.debug(url) self._last_searched = f" to " - async with self._session.get(url, params=params) as resp: - data = await resp.json() - games = [game for date in data["dates"] for game in date["games"]] + team = None + if self.team: + team = self.team[0] + data = await self.api.get_schedule(team, date, end_date) + games = data.games self.select_options = [] # log.debug(games) for count, game in enumerate(games): - home_team = game["teams"]["home"]["team"]["name"] + home_team = game.home_team home_abr = home_team if home_team in TEAMS: home_abr = TEAMS[home_team]["tri_code"] - away_team = game["teams"]["away"]["team"]["name"] + away_team = game.away_team away_abr = away_team if away_team in TEAMS: away_abr = TEAMS[away_team]["tri_code"] if self.vs and (home_team not in self.team or away_team not in self.team): continue - date = utc_to_local(datetime.strptime(game["gameDate"], "%Y-%m-%dT%H:%M:%SZ")) + date = game.game_start label = f"{away_abr}@{home_abr}-{date.year}-{date.month}-{date.day}" description = f"{away_team} @ {home_team}" emoji = None @@ -249,7 +231,7 @@ async def _next_batch( emoji = discord.PartialEmoji.from_str("\N{HOUSE BUILDING}") self.select_options.append( discord.SelectOption( - label=label, value=str(game["gamePk"]), description=description, emoji=emoji + label=label, value=str(game.id), description=description, emoji=emoji ) ) if not games: @@ -279,10 +261,10 @@ def __init__(self, **kwargs): if self.team is None: self.team = [] self._last_searched: str = "" - self._session: aiohttp.ClientSession = kwargs.get("session") self.timezone: Optional[str] = kwargs.get("timezone") self.get_recap: bool = kwargs.get("get_recap", False) self.show_broadcasts = kwargs.get("show_broadcasts", False) + self.api = kwargs["api"] @property def index(self) -> int: @@ -318,30 +300,31 @@ async def get_page( self._last_page = page_number return page - async def format_page(self, menu: menus.MenuPages, games: List[dict]) -> discord.Embed: + async def format_page( + self, menu: menus.MenuPages, games: List[ScheduledGame] + ) -> discord.Embed: states = { - "Preview": "\N{LARGE RED CIRCLE}", - "Live": "\N{LARGE GREEN CIRCLE}", + GameState.preview: "\N{LARGE RED CIRCLE}", + GameState.live: "\N{LARGE GREEN CIRCLE}", "Intermission": "\N{LARGE YELLOW CIRCLE}", - "Final": "\N{CHEQUERED FLAG}", + GameState.final: "\N{CHEQUERED FLAG}", } # log.debug(games) msg = humanize_list(self.team) + "\n" day = None start_time = None for game in games: - game_start = datetime.strptime(game["gameDate"], "%Y-%m-%dT%H:%M:%SZ") - game_start = game_start.replace(tzinfo=timezone.utc) - home_team = game["teams"]["home"]["team"]["name"] - away_team = game["teams"]["away"]["team"]["name"] + game_start = game.game_start + home_team = game.home_team + away_team = game.away_team home_emoji = discord.PartialEmoji.from_str("\N{HOUSE BUILDING}") away_emoji = discord.PartialEmoji.from_str("\N{AIRPLANE}") home_abr = home_team away_abr = away_team broadcast_str = "" log.verbose("ScheduleList game: %s", game) - if "broadcasts" in game and self.show_broadcasts: - broadcasts = game["broadcasts"] + if game.broadcasts and self.show_broadcasts: + broadcasts = game.broadcasts if broadcasts: broadcast_str = ( "- " @@ -356,9 +339,9 @@ async def format_page(self, menu: menus.MenuPages, games: List[dict]) -> discord away_emoji = discord.PartialEmoji.from_str(TEAMS[away_team]["emoji"]) away_abr = TEAMS[away_team]["tri_code"] - postponed = game["status"]["detailedState"] == "Postponed" + postponed = game.schedule_state != "OK" try: - game_state = states[game["status"]["abstractGameState"]] + game_state = states[game.game_state] except KeyError: game_state = "\N{LARGE RED CIRCLE}" if start_time is None: @@ -381,14 +364,18 @@ async def format_page(self, menu: menus.MenuPages, games: List[dict]) -> discord f"{home_emoji} {home_abr} - {time_str}\n{broadcast_str}\n" ) elif game_start < datetime.now(timezone.utc): - home_score = game["teams"]["home"]["score"] - away_score = game["teams"]["away"]["score"] - if self.get_recap: - game_recap = await Game.get_game_recap(game["gamePk"], session=self._session) + home_score = game.home_score + away_score = game.away_score + if not self.get_recap: msg += ( - f"[{game_state} - {away_emoji} {away_abr} **{away_score}** - " - f"**{home_score}** {home_emoji} {home_abr}]({game_recap}) \n{broadcast_str}\n" + f"{game_state} - {away_emoji} {away_abr} **{away_score}** - " + f"**{home_score}** {home_emoji} {home_abr} \n{broadcast_str}\n" ) + # game_recap = await Game.get_game_recap(game["gamePk"], session=self._session) + # msg += ( + # f"[{game_state} - {away_emoji} {away_abr} **{away_score}** - " + # f"**{home_score}** {home_emoji} {home_abr}]({game_recap}) \n{broadcast_str}\n" + # ) else: msg += ( f"{game_state} - {away_emoji} {away_abr} **{away_score}** - " @@ -519,21 +506,20 @@ async def _next_batch( end_date_timestamp = int( utc_to_local((self.date + timedelta(days=days_to_check)), "UTC").timestamp() ) - - url = f"{BASE_URL}/api/v1/schedule" - params = { - "startDate": date_str, - "endDate": end_date_str, - "expand": "schedule.teams,schedule.linescore,schedule.broadcasts", - } - if self.team not in ["all", None]: - # if a team is provided get just that TEAMS data - params["teamId"] = ",".join(str(TEAMS[t]["id"]) for t in self.team) self._last_searched = f" to " - async with self._session.get(url, params=params) as resp: - log.verbose("_next_batch Response URL: %s", resp.url) - data = await resp.json() - games = [game for date in data["dates"] for game in date["games"]] + team = None + if self.team: + team = self.team[0] + data = await self.api.get_schedule(team, date, end_date) + days = data.days + if not days: + # log.debug("No schedule, looking for more days") + if self._checks < self.limit: + self._checks += 1 + games = await self._next_batch(date=self.date, _next=_next, _prev=_prev) + else: + raise NoSchedule + games = days[0] if not games: # log.debug("No schedule, looking for more days") if self._checks < self.limit: diff --git a/hockey/standings.py b/hockey/standings.py index 3915dc2cd1..b6281edfcb 100644 --- a/hockey/standings.py +++ b/hockey/standings.py @@ -39,6 +39,12 @@ class StreakType(Enum): wins = "wins" losses = "losses" + @classmethod + def from_code(cls, code: str) -> StreakType: + return {"W": StreakType.wins, "L": StreakType.losses, "OT": StreakType.ot}.get( + code, StreakType.wins + ) + @dataclass class Team: @@ -62,7 +68,10 @@ class Streak: streakCode: str def __init__(self, *args, **kwargs): - self.streakType = StreakType(kwargs["streakType"]) + try: + self.streakType = StreakType(kwargs["streakType"]) + except ValueError: + self.streakType = StreakType.from_code(kwargs["streakCode"]) self.streakNumber = int(kwargs["streakNumber"]) self.streakCode = kwargs["streakCode"] @@ -471,6 +480,61 @@ def from_json(cls, data: dict, division: Division, conference: Conference) -> Te last_updated=datetime.strptime(data["lastUpdated"], "%Y-%m-%dT%H:%M:%SZ"), ) + @classmethod + def from_nhle(cls, data: dict) -> TeamRecord: + team_name = data["teamName"].get("default") + team_info = TEAMS.get(team_name) + team = Team(id=team_info["id"], name=team_name, link=team_info["team_url"]) + division = Division( + id=0, + name=data["divisionName"], + nameShort="", + abbreviation=data["divisionAbbrev"], + link=None, + ) + conference = Conference(id=0, name=data["conferenceName"], link=None) + league_record = LeagueRecord( + wins=data["wins"], losses=data["losses"], ot=data["otLosses"], type="league" + ) + streak = Streak( + **{ + "streakType": "wins", + "streakNumber": data["streakCount"], + "streakCode": data["streakCode"], + } + ) + return cls( + team=team, + division=division, + conference=conference, + league_record=league_record, + regulation_wins=int(data["regulationWins"]), + goals_against=int(data["goalAgainst"]), + goals_scored=int(data["goalFor"]), + points=int(data["points"]), + division_rank=int(data["divisionSequence"]), + division_l10_rank=int(data["divisionL10Sequence"]), + division_road_rank=int(data["divisionRoadSequence"]), + division_home_rank=int(data["divisionHomeSequence"]), + conference_rank=int(data["conferenceSequence"]), + conference_l10_rank=int(data["conferenceL10Sequence"]), + conference_road_rank=int(data["conferenceRoadSequence"]), + conference_home_rank=int(data["conferenceHomeSequence"]), + league_rank=int(data["leagueSequence"]), + league_l10_rank=int(data["leagueL10Sequence"]), + league_road_rank=int(data["leagueRoadSequence"]), + league_home_rank=int(data["leagueHomeSequence"]), + wildcard_rank=int(data["wildcardSequence"]), + row=0, + games_played=int(data["gamesPlayed"]), + streak=streak, + points_percentage=float(data["pointPctg"]), + pp_division_rank=0, + pp_conference_rank=0, + pp_league_rank=0, + last_updated=datetime.now(timezone.utc), + ) + class Standings: def __init__(self, records: dict = {}): @@ -496,6 +560,14 @@ def last_timestamp( latest = record.last_updated return latest or datetime.now(timezone.utc) + @classmethod + def from_nhle(cls, data: dict) -> Standings: + all_records = {} + for team in data["standings"]: + record = TeamRecord.from_nhle(team) + all_records[record.team.name] = record + return cls(records=all_records) + @classmethod async def get_team_standings( cls, @@ -530,8 +602,9 @@ async def post_automatic_standings(bot) -> None: run when new games for the day is updated """ log.debug("Updating Standings.") - config = bot.get_cog("Hockey").config - standings = await Standings.get_team_standings() + cog = bot.get_cog("Hockey") + config = cog.config + standings = await cog.api.get_standings() all_guilds = await config.all_guilds() async for guild_id, data in AsyncIter(all_guilds.items(), steps=100): diff --git a/hockey/teamentry.py b/hockey/teamentry.py index e845481852..201c1619d3 100644 --- a/hockey/teamentry.py +++ b/hockey/teamentry.py @@ -1,7 +1,7 @@ class TeamEntry: def __init__( self, - game_state: str, + game_state: int, team_name: str, period: int, channel: list,