diff --git a/plugins.json b/plugins.json index 9cf5508..942decf 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-07-08", + "last_updated": "2026-07-12", "plugins": [ { "id": "7-segment-clock", @@ -507,7 +507,7 @@ { "id": "on-air", "name": "On Air Light", - "description": "Retro broadcast ON AIR tally light. Activate remotely via MQTT or Home Assistant to signal you're on a call, recording, or live \u2014 stays on until you turn it off.", + "description": "Retro broadcast ON AIR tally light. Activate remotely via MQTT or Home Assistant to signal you're on a call, recording, or live — stays on until you turn it off.", "author": "ChuckBuilds", "category": "utility", "tags": [ @@ -654,7 +654,7 @@ { "id": "plex-marquee", "name": "Plex Marquee", - "description": "Cinema marquee display powered by Fanart.tv \u2014 shows banner art for whatever is currently playing on Plex, with a web portal to browse and pick banners for your library.", + "description": "Cinema marquee display powered by Fanart.tv — shows banner art for whatever is currently playing on Plex, with a web portal to browse and pick banners for your library.", "author": "ant456", "category": "media", "tags": [ @@ -991,10 +991,10 @@ "plugin_path": "plugins/nrl-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-07-10", + "last_updated": "2026-07-12", "verified": true, "screenshot": "", - "latest_version": "1.0.0" + "latest_version": "1.0.1" } ] -} \ No newline at end of file +} diff --git a/plugins/nrl-scoreboard/README.md b/plugins/nrl-scoreboard/README.md index d61b895..a87d9ff 100644 --- a/plugins/nrl-scoreboard/README.md +++ b/plugins/nrl-scoreboard/README.md @@ -64,8 +64,8 @@ options (see `config_schema.json` for the full list, types, and defaults): | Key | Description | |---|---| | `enabled` | Master on/off switch | -| `favorite_teams` | List of team names/abbreviations to prioritize | -| `exclude_teams` | Teams to hide (spoiler protection) | +| `favorite_teams` | List of favorite NRL teams to prioritize. Use the full team name (e.g. `"Newcastle Knights"`) or ESPN team ID, **not** the 3-letter abbreviation — some abbreviations are shared by two teams (`NEW` is both Newcastle Knights and New Zealand Warriors; `CAN` is both Canberra Raiders and Canterbury Bulldogs) and would match both | +| `exclude_teams` | Teams to hide (spoiler protection). Same name/ID guidance as `favorite_teams` | | `display_modes` | Toggle `live`/`recent`/`upcoming` and set `*_display_mode` to `switch` or `scroll` | | `live_priority` | Interrupt the rotation to show live games immediately | | `live_game_duration` / `recent_game_duration` / `upcoming_game_duration` | Per-game on-screen time (seconds) | @@ -85,7 +85,7 @@ options (see `config_schema.json` for the full list, types, and defaults): { "nrl-scoreboard": { "enabled": true, - "favorite_teams": ["PEN", "BRI"], + "favorite_teams": ["Penrith Panthers", "Brisbane Broncos"], "display_modes": { "live": true, "live_display_mode": "switch", diff --git a/plugins/nrl-scoreboard/config_schema.json b/plugins/nrl-scoreboard/config_schema.json index 69493e9..2a40f05 100644 --- a/plugins/nrl-scoreboard/config_schema.json +++ b/plugins/nrl-scoreboard/config_schema.json @@ -13,22 +13,22 @@ "type": "array", "items": { "type": "string", - "description": "NRL team name or abbreviation" + "description": "NRL full team name (e.g. 'Newcastle Knights') or ESPN team ID. Avoid 3-letter abbreviations - some are shared by two teams (e.g. 'NEW' is both Newcastle Knights and New Zealand Warriors, 'CAN' is both Canberra Raiders and Canterbury Bulldogs) and will match both." }, "uniqueItems": true, "maxItems": 20, - "description": "List of favorite NRL teams to prioritize" + "description": "List of favorite NRL teams to prioritize. Use the full team name or ESPN team ID - some abbreviations (e.g. NEW, CAN) are shared by two different NRL teams." }, "exclude_teams": { "type": "array", "items": { "type": "string", - "description": "NRL team name or abbreviation" + "description": "NRL full team name (e.g. 'Newcastle Knights') or ESPN team ID. Avoid 3-letter abbreviations - some are shared by two teams (e.g. 'NEW' is both Newcastle Knights and New Zealand Warriors, 'CAN' is both Canberra Raiders and Canterbury Bulldogs) and will match both." }, "uniqueItems": true, "maxItems": 20, "default": [], - "description": "Team abbreviations to always hide from live rotation and recent/final scores for this league (spoiler protection). Takes precedence over Favorite Teams and Show All Live." + "description": "Teams to always hide from live rotation and recent/final scores for this league (spoiler protection). Takes precedence over Favorite Teams and Show All Live. Use the full team name or ESPN team ID - some abbreviations (e.g. NEW, CAN) are shared by two different NRL teams." }, "display_modes": { "type": "object", diff --git a/plugins/nrl-scoreboard/dynamic_team_resolver.py b/plugins/nrl-scoreboard/dynamic_team_resolver.py index f57b66a..2d3b6cc 100644 --- a/plugins/nrl-scoreboard/dynamic_team_resolver.py +++ b/plugins/nrl-scoreboard/dynamic_team_resolver.py @@ -1,52 +1,71 @@ """ -Simplified DynamicTeamResolver for NRL plugin use +DynamicTeamResolver for the NRL plugin + +Resolves user-configured team strings (favorite_teams / exclude_teams) to +ESPN team IDs. + +NRL's ESPN abbreviations are NOT unique: Newcastle Knights and New Zealand +Warriors both use "NEW", and Canberra Raiders and Canterbury Bulldogs both +use "CAN". Matching favorite/excluded teams by abbreviation therefore +silently conflates unrelated teams (a user configuring "NEW" for the +Newcastle Knights would also match every New Zealand Warriors game). + +To avoid that, this resolver converts each configured entry into a unique +ESPN team ID by fetching the NRL team list once (cached in-process) and +matching against team names first, then abbreviations - but only when an +abbreviation maps to exactly one team. Every downstream membership check in +sports.py compares team IDs (home_id/away_id) against the output of +resolve_teams(), so resolution here must produce team IDs. """ import logging import time -from typing import Dict, List +from typing import Dict, List, Optional, Tuple + +import requests logger = logging.getLogger(__name__) +NRL_TEAMS_URL = "https://site.api.espn.com/apis/site/v2/sports/rugby-league/3/teams?limit=50" + + class DynamicTeamResolver: """ - Simplified resolver for dynamic team names to actual team abbreviations. - - This class handles special team names that represent dynamic groups - for NRL. + Resolves NRL team names/abbreviations/IDs to canonical ESPN team IDs. """ - - # Cache for rankings data - _rankings_cache: Dict[str, List[str]] = {} - _cache_timestamp: float = 0 - _cache_duration: int = 3600 # 1 hour cache - - def __init__(self, request_timeout: int = 30): + + # Class-level cache shared across instances/managers so the teams list + # is fetched at most once per process (per cache_duration window). + _teams_index_cache: Dict[str, dict] = {} + _teams_index_timestamp: Dict[str, float] = {} + _cache_duration: int = 24 * 3600 # team ids/abbreviations don't change intra-season + + def __init__(self, request_timeout: int = 15): """Initialize the dynamic team resolver.""" self.request_timeout = request_timeout self.logger = logger - + def resolve_teams(self, team_list: List[str], sport: str = 'rugby-league') -> List[str]: """ - Resolve a list of team names, expanding dynamic team names. - + Resolve a list of team names/abbreviations/IDs to ESPN team IDs. + Args: - team_list: List of team names + team_list: List of team names, abbreviations, or ESPN team IDs sport: Sport type for context (default: 'rugby-league') - + Returns: - List of resolved team abbreviations + List of resolved ESPN team IDs (as strings). Entries that could + not be resolved (fetch failed, no match, ambiguous abbreviation) + are passed through unchanged and logged so the underlying issue + is visible. """ if not team_list: return [] - - resolved_teams = [] - - for team in team_list: - # For NRL, we just pass through team names as-is - # No dynamic patterns like AP_TOP_25 for NRL - resolved_teams.append(team) - + + index = self._get_teams_index(sport) + + resolved_teams = [self._resolve_one(team, index) for team in team_list] + # Remove duplicates while preserving order seen = set() unique_teams = [] @@ -54,10 +73,124 @@ def resolve_teams(self, team_list: List[str], sport: str = 'rugby-league') -> Li if team not in seen: seen.add(team) unique_teams.append(team) - + return unique_teams - - def _is_cache_valid(self) -> bool: - """Check if the rankings cache is still valid.""" - return time.time() - self._cache_timestamp < self._cache_duration + def _resolve_one(self, team: str, index: Optional[dict]) -> str: + raw = (team or "").strip() + if not raw: + return raw + + if raw.isdigit(): + # Already an ESPN team ID. + return raw + + if not index: + self.logger.warning( + f"NRL team list unavailable; using '{raw}' as-is for " + "favorite/exclude matching. If this is an abbreviation it " + "may not be unique (e.g. 'NEW' matches both Newcastle " + "Knights and New Zealand Warriors)." + ) + return raw + + key = raw.lower() + + by_name = index["by_name"] + if key in by_name: + return by_name[key] + + by_abbr = index["by_abbr"] + candidates = by_abbr.get(key) + if not candidates: + self.logger.warning( + f"Could not resolve NRL team '{raw}' to a known team; using " + "it as-is. Use the full team name (e.g. 'Newcastle Knights') " + "or the ESPN team ID instead of an abbreviation." + ) + return raw + + if len(candidates) > 1: + names = ", ".join(f"{name} (id {tid})" for tid, name in candidates) + self.logger.error( + f"NRL abbreviation '{raw}' is ambiguous - it matches multiple " + f"teams: {names}. Configure the full team name or the ESPN " + "team ID in favorite_teams/exclude_teams instead." + ) + return raw + + return candidates[0][0] + + def _get_teams_index(self, sport: str) -> Optional[dict]: + if sport not in ('rugby-league', 'nrl'): + return None + + now = time.time() + cached = DynamicTeamResolver._teams_index_cache.get(sport) + cached_at = DynamicTeamResolver._teams_index_timestamp.get(sport, 0) + if cached and (now - cached_at) < self._cache_duration: + return cached + + fetched = self._fetch_teams_index() + if fetched: + DynamicTeamResolver._teams_index_cache[sport] = fetched + DynamicTeamResolver._teams_index_timestamp[sport] = now + return fetched + + # Fetch failed: fall back to a stale cache rather than nothing. + return cached + + def _fetch_teams_index(self) -> Optional[dict]: + try: + response = requests.get(NRL_TEAMS_URL, timeout=self.request_timeout) + response.raise_for_status() + data = response.json() + except Exception as e: + self.logger.warning( + f"Failed to fetch NRL team list for favorite/exclude team " + f"resolution: {e}" + ) + return None + + return self._build_index(data) + + @staticmethod + def _build_index(data: dict) -> Optional[dict]: + try: + teams = data["sports"][0]["leagues"][0]["teams"] + except (KeyError, IndexError, TypeError): + logger.warning( + "Unexpected NRL teams response shape; cannot resolve " + "favorite/exclude teams" + ) + return None + + by_name: Dict[str, str] = {} + by_abbr: Dict[str, List[Tuple[str, str]]] = {} + + for entry in teams: + t = entry.get("team", {}) + team_id = t.get("id") + if not team_id: + continue + display_name = t.get("displayName") or t.get("name") or "" + + for name in ( + t.get("displayName"), + t.get("shortDisplayName"), + t.get("name"), + t.get("location"), + t.get("nickname"), + ): + if name: + by_name[name.strip().lower()] = team_id + + abbr = t.get("abbreviation") + if abbr: + by_abbr.setdefault(abbr.strip().lower(), []).append( + (team_id, display_name or abbr) + ) + + if not by_name and not by_abbr: + return None + return {"by_name": by_name, "by_abbr": by_abbr} diff --git a/plugins/nrl-scoreboard/manager.py b/plugins/nrl-scoreboard/manager.py index 6a25d5b..0020a4b 100644 --- a/plugins/nrl-scoreboard/manager.py +++ b/plugins/nrl-scoreboard/manager.py @@ -669,8 +669,8 @@ def _has_favorite_or_all_live(self, live_manager) -> bool: favorite_teams = getattr(live_manager, "favorite_teams", []) if favorite_teams: return any( - game.get("home_abbr") in favorite_teams - or game.get("away_abbr") in favorite_teams + str(game.get("home_id")) in favorite_teams + or str(game.get("away_id")) in favorite_teams for game in live_games ) return False diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index 4f8bb51..d04bfd8 100644 --- a/plugins/nrl-scoreboard/manifest.json +++ b/plugins/nrl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "nrl-scoreboard", "name": "NRL Scoreboard", - "version": "1.0.0", + "version": "1.0.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NRL (National Rugby League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,12 @@ "nrl_upcoming" ], "versions": [ + { + "released": "2026-07-12", + "version": "1.0.1", + "notes": "Fix favorite_teams/exclude_teams matching: NRL abbreviations aren't unique (\"NEW\" is both Newcastle Knights and New Zealand Warriors, \"CAN\" is both Canberra Raiders and Canterbury Bulldogs). Team matching now resolves to unique ESPN team IDs instead of relying on the ambiguous abbreviation.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-10", "version": "1.0.0", @@ -25,7 +31,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-07-10", + "last_updated": "2026-07-12", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/nrl-scoreboard/sports.py b/plugins/nrl-scoreboard/sports.py index 5a12e5f..aaeeff7 100644 --- a/plugins/nrl-scoreboard/sports.py +++ b/plugins/nrl-scoreboard/sports.py @@ -185,8 +185,8 @@ def __init__( def _is_favorite_game(self, game: Dict) -> bool: return bool(self.favorite_teams) and ( - game.get("home_abbr") in self.favorite_teams - or game.get("away_abbr") in self.favorite_teams + str(game.get("home_id")) in self.favorite_teams + or str(game.get("away_id")) in self.favorite_teams ) def _effective_live_duration(self, game): @@ -207,14 +207,19 @@ def _effective_live_duration(self, game): return non_fav return self.game_display_duration - def _classify_live_game(self, home_abbr, away_abbr) -> bool: + def _classify_live_game(self, home_id, away_id) -> bool: """Whether a live game should be included in the live rotation. Priority: excluded team (never shown, overrides everything) > show_all_live > show_favorite_teams_only disabled > no favorites configured (fallback: show all) > favorite-teams-only membership. + + Matched by ESPN team ID (not abbreviation) - NRL abbreviations are + not unique (e.g. "NEW" is both Newcastle Knights and New Zealand + Warriors, "CAN" is both Canberra Raiders and Canterbury Bulldogs). """ - if home_abbr in self.exclude_teams or away_abbr in self.exclude_teams: + home_id, away_id = str(home_id), str(away_id) + if home_id in self.exclude_teams or away_id in self.exclude_teams: return False if self.show_all_live: return True @@ -222,7 +227,7 @@ def _classify_live_game(self, home_abbr, away_abbr) -> bool: return True if not self.favorite_teams: return True - return home_abbr in self.favorite_teams or away_abbr in self.favorite_teams + return home_id in self.favorite_teams or away_id in self.favorite_teams def _swrr_advance(self, games: List[Dict]) -> Optional[Dict]: """Pick the next live game to display via Smooth Weighted Round-Robin. @@ -924,8 +929,10 @@ def _extract_game_details_common( away_abbr = away_team["team"]["name"][:3] # Check if this is a favorite team game BEFORE doing expensive logging + # (matched by team ID - NRL abbreviations like "NEW"/"CAN" aren't unique). is_favorite_game = self.favorite_teams and ( - home_abbr in self.favorite_teams or away_abbr in self.favorite_teams + str(home_team["id"]) in self.favorite_teams + or str(away_team["id"]) in self.favorite_teams ) # Only log debug info for favorite team games @@ -1254,8 +1261,8 @@ def _select_games_for_display( if game_id in selected_ids: continue - home = game.get("home_abbr") - away = game.get("away_abbr") + home = str(game.get("home_id")) + away = str(game.get("away_id")) home_fav = home in favorite_teams away_fav = away in favorite_teams @@ -1275,7 +1282,7 @@ def _select_games_for_display( team_counts[away] += 1 self.logger.debug( - f"Selected game {away}@{home}: team_counts={team_counts}" + f"Selected game {game.get('away_abbr')}@{game.get('home_abbr')}: team_counts={team_counts}" ) if all(c >= self.upcoming_games_to_show for c in team_counts.values()): @@ -1331,15 +1338,15 @@ def update(self): # If show_favorite_teams_only is True but no favorites configured, show all if self.show_favorite_teams_only and self.favorite_teams: if ( - game["home_abbr"] not in self.favorite_teams - and game["away_abbr"] not in self.favorite_teams + str(game["home_id"]) not in self.favorite_teams + and str(game["away_id"]) not in self.favorite_teams ): continue processed_games.append(game) # Count favorite team games for logging if self.favorite_teams and ( - game["home_abbr"] in self.favorite_teams - or game["away_abbr"] in self.favorite_teams + str(game["home_id"]) in self.favorite_teams + or str(game["away_id"]) in self.favorite_teams ): favorite_games_found += 1 if self.show_odds: @@ -1804,8 +1811,8 @@ def _select_recent_games_for_display( if game_id in selected_ids: continue - home = game.get("home_abbr") - away = game.get("away_abbr") + home = str(game.get("home_id")) + away = str(game.get("away_id")) home_fav = home in favorite_teams away_fav = away in favorite_teams @@ -1825,7 +1832,7 @@ def _select_recent_games_for_display( team_counts[away] += 1 self.logger.debug( - f"Selected recent game {away}@{home}: team_counts={team_counts}" + f"Selected recent game {game.get('away_abbr')}@{game.get('home_abbr')}: team_counts={team_counts}" ) if all(c >= self.recent_games_to_show for c in team_counts.values()): @@ -1900,8 +1907,8 @@ def update(self): # Excluded teams are hidden from final/recent scores too # (spoiler protection), regardless of every other filter. if ( - game.get("home_abbr") in self.exclude_teams - or game.get("away_abbr") in self.exclude_teams + str(game.get("home_id")) in self.exclude_teams + or str(game.get("away_id")) in self.exclude_teams ): continue processed_games.append(game) @@ -2393,12 +2400,12 @@ def _score_to_int(score) -> Optional[int]: except (ValueError, TypeError): return None - def _is_favorite(self, abbr: Optional[str]) -> bool: - return bool(self.favorite_teams) and abbr in self.favorite_teams + def _is_favorite(self, team_id: Optional[str]) -> bool: + return bool(self.favorite_teams) and str(team_id) in self.favorite_teams - def _should_celebrate_goal_for(self, abbr: Optional[str]) -> bool: - """Whether a goal by ``abbr`` should trigger a celebration.""" - if self._is_favorite(abbr): + def _should_celebrate_goal_for(self, team_id: Optional[str]) -> bool: + """Whether a goal by ``team_id`` should trigger a celebration.""" + if self._is_favorite(team_id): return True if not self.favorite_teams: # No favorites configured: the user opted to show this game, so @@ -2473,10 +2480,10 @@ def _check_for_goal(self, game: Dict) -> None: return scored_side = None - if away_scored and self._should_celebrate_goal_for(game.get("away_abbr")): + if away_scored and self._should_celebrate_goal_for(game.get("away_id")): scored_side = "away" if scored_side is None and home_scored and self._should_celebrate_goal_for( - game.get("home_abbr") + game.get("home_id") ): scored_side = "home" if scored_side is None: @@ -2513,22 +2520,22 @@ def _check_for_win(self, game: Dict) -> None: return if away > home: - winner_side, winner_abbr = "away", game.get("away_abbr") + winner_side, winner_id = "away", game.get("away_id") elif home > away: - winner_side, winner_abbr = "home", game.get("home_abbr") + winner_side, winner_id = "home", game.get("home_id") else: return # draw -> no win celebration # Wins are gated strictly on favorites (every game ends, so the # "no favorites -> celebrate all" goal fallback would be too noisy). - if not self._is_favorite(winner_abbr): + if not self._is_favorite(winner_id): return self._start_celebration( game, "win", scored_side=winner_side, - team_abbr=winner_abbr, + team_abbr=game.get(f"{winner_side}_abbr", ""), away_score=away, home_score=home, ) @@ -2936,7 +2943,7 @@ def update(self): # full precedence order: exclude > show_all_live > # favorites-only membership; matches SportsUpcoming). should_include = self._classify_live_game( - details["home_abbr"], details["away_abbr"] + details["home_id"], details["away_id"] ) if not should_include: diff --git a/plugins/nrl-scoreboard/test/fixtures/mock.json b/plugins/nrl-scoreboard/test/fixtures/mock.json new file mode 100644 index 0000000..28f09e7 --- /dev/null +++ b/plugins/nrl-scoreboard/test/fixtures/mock.json @@ -0,0 +1,265 @@ +{ + "leagues": [ + { + "id": "8370", + "name": "Rugby League", + "abbreviation": "NRL", + "slug": "3" + } + ], + "season": { + "type": 1, + "year": 2026 + }, + "events": [ + { + "id": "401700001", + "date": "2026-07-10T09:00Z", + "name": "Penrith Panthers at Brisbane Broncos", + "shortName": "PEN @ BRI", + "season": { + "year": 2026, + "type": 1 + }, + "competitions": [ + { + "id": "401700001", + "date": "2026-07-10T09:00Z", + "status": { + "clock": 3120.0, + "displayClock": "52'", + "period": 2, + "type": { + "id": "2", + "name": "STATUS_IN_PROGRESS", + "state": "in", + "completed": false, + "description": "In Progress", + "detail": "2nd Half", + "shortDetail": "2H" + } + }, + "competitors": [ + { + "id": "1", + "homeAway": "home", + "winner": false, + "score": "18", + "team": { + "id": "16", + "abbreviation": "BRI", + "displayName": "Brisbane Broncos", + "shortDisplayName": "Broncos", + "logo": "https://a.espncdn.com/i/teamlogos/rugby/teams/500/16.png" + }, + "records": [ + { + "name": "overall", + "type": "total", + "summary": "WWLWW" + } + ] + }, + { + "id": "2", + "homeAway": "away", + "winner": false, + "score": "12", + "team": { + "id": "18", + "abbreviation": "PEN", + "displayName": "Penrith Panthers", + "shortDisplayName": "Panthers", + "logo": "https://a.espncdn.com/i/teamlogos/rugby/teams/500/18.png" + }, + "records": [ + { + "name": "overall", + "type": "total", + "summary": "WWWLW" + } + ] + } + ] + } + ], + "status": { + "clock": 3120.0, + "displayClock": "52'", + "period": 2, + "type": { + "id": "2", + "name": "STATUS_IN_PROGRESS", + "state": "in", + "completed": false + } + } + }, + { + "id": "401700002", + "date": "2026-07-09T09:00Z", + "name": "Sydney Roosters at Melbourne Storm", + "shortName": "SYD @ MEL", + "season": { + "year": 2026, + "type": 1 + }, + "competitions": [ + { + "id": "401700002", + "date": "2026-07-09T09:00Z", + "status": { + "clock": 4800.0, + "displayClock": "80'", + "period": 2, + "type": { + "id": "3", + "name": "STATUS_FINAL", + "state": "post", + "completed": true, + "description": "Final", + "detail": "Final", + "shortDetail": "Final" + } + }, + "competitors": [ + { + "id": "3", + "homeAway": "home", + "winner": true, + "score": "24", + "team": { + "id": "12", + "abbreviation": "MEL", + "displayName": "Melbourne Storm", + "shortDisplayName": "Storm", + "logo": "https://a.espncdn.com/i/teamlogos/rugby/teams/500/12.png" + }, + "records": [ + { + "name": "overall", + "type": "total", + "summary": "WWWWL" + } + ] + }, + { + "id": "4", + "homeAway": "away", + "winner": false, + "score": "10", + "team": { + "id": "20", + "abbreviation": "SYD", + "displayName": "Sydney Roosters", + "shortDisplayName": "Roosters", + "logo": "https://a.espncdn.com/i/teamlogos/rugby/teams/500/20.png" + }, + "records": [ + { + "name": "overall", + "type": "total", + "summary": "LWLWL" + } + ] + } + ] + } + ], + "status": { + "clock": 4800.0, + "displayClock": "80'", + "period": 2, + "type": { + "id": "3", + "name": "STATUS_FINAL", + "state": "post", + "completed": true + } + } + }, + { + "id": "401700003", + "date": "2026-07-12T09:00Z", + "name": "Penrith Panthers at Parramatta Eels", + "shortName": "PEN @ PAR", + "season": { + "year": 2026, + "type": 1 + }, + "competitions": [ + { + "id": "401700003", + "date": "2026-07-12T09:00Z", + "status": { + "clock": 0.0, + "displayClock": "0'", + "period": 0, + "type": { + "id": "1", + "name": "STATUS_SCHEDULED", + "state": "pre", + "completed": false, + "description": "Scheduled", + "detail": "Sun, July 12th at 9:00 AM UTC", + "shortDetail": "7/12 - 9:00 AM UTC" + } + }, + "competitors": [ + { + "id": "5", + "homeAway": "home", + "winner": false, + "score": "0", + "team": { + "id": "14", + "abbreviation": "PAR", + "displayName": "Parramatta Eels", + "shortDisplayName": "Eels", + "logo": "https://a.espncdn.com/i/teamlogos/rugby/teams/500/14.png" + }, + "records": [ + { + "name": "overall", + "type": "total", + "summary": "LLWLW" + } + ] + }, + { + "id": "6", + "homeAway": "away", + "winner": false, + "score": "0", + "team": { + "id": "18", + "abbreviation": "PEN", + "displayName": "Penrith Panthers", + "shortDisplayName": "Panthers", + "logo": "https://a.espncdn.com/i/teamlogos/rugby/teams/500/18.png" + }, + "records": [ + { + "name": "overall", + "type": "total", + "summary": "WWWLW" + } + ] + } + ] + } + ], + "status": { + "clock": 0.0, + "displayClock": "0'", + "period": 0, + "type": { + "id": "1", + "name": "STATUS_SCHEDULED", + "state": "pre", + "completed": false + } + } + } + ] +} diff --git a/plugins/nrl-scoreboard/test/harness.json b/plugins/nrl-scoreboard/test/harness.json index b2443b3..7cc433e 100644 --- a/plugins/nrl-scoreboard/test/harness.json +++ b/plugins/nrl-scoreboard/test/harness.json @@ -3,7 +3,10 @@ "config": { "enabled": true, "timezone": "UTC", - "favorite_teams": ["PEN", "BRI"], + "favorite_teams": [ + "18", + "16" + ], "display_modes": { "live": true, "live_display_mode": "switch", @@ -21,221 +24,5 @@ "upcoming_game_duration": 15 }, "freeze_time": "2026-07-10 09:30:00", - "mock_data": { - "leagues": [ - { - "id": "8370", - "name": "Rugby League", - "abbreviation": "NRL", - "slug": "3" - } - ], - "season": { "type": 1, "year": 2026 }, - "events": [ - { - "id": "401700001", - "date": "2026-07-10T09:00Z", - "name": "Penrith Panthers at Brisbane Broncos", - "shortName": "PEN @ BRI", - "season": { "year": 2026, "type": 1 }, - "competitions": [ - { - "id": "401700001", - "date": "2026-07-10T09:00Z", - "status": { - "clock": 3120.0, - "displayClock": "52'", - "period": 2, - "type": { - "id": "2", - "name": "STATUS_IN_PROGRESS", - "state": "in", - "completed": false, - "description": "In Progress", - "detail": "2nd Half", - "shortDetail": "2H" - } - }, - "competitors": [ - { - "id": "1", - "homeAway": "home", - "winner": false, - "score": "18", - "team": { - "id": "16", - "abbreviation": "BRI", - "displayName": "Brisbane Broncos", - "shortDisplayName": "Broncos", - "logo": "https://a.espncdn.com/i/teamlogos/rugby/teams/500/16.png" - }, - "records": [{ "name": "overall", "type": "total", "summary": "WWLWW" }] - }, - { - "id": "2", - "homeAway": "away", - "winner": false, - "score": "12", - "team": { - "id": "18", - "abbreviation": "PEN", - "displayName": "Penrith Panthers", - "shortDisplayName": "Panthers", - "logo": "https://a.espncdn.com/i/teamlogos/rugby/teams/500/18.png" - }, - "records": [{ "name": "overall", "type": "total", "summary": "WWWLW" }] - } - ] - } - ], - "status": { - "clock": 3120.0, - "displayClock": "52'", - "period": 2, - "type": { - "id": "2", - "name": "STATUS_IN_PROGRESS", - "state": "in", - "completed": false - } - } - }, - { - "id": "401700002", - "date": "2026-07-09T09:00Z", - "name": "Sydney Roosters at Melbourne Storm", - "shortName": "SYD @ MEL", - "season": { "year": 2026, "type": 1 }, - "competitions": [ - { - "id": "401700002", - "date": "2026-07-09T09:00Z", - "status": { - "clock": 4800.0, - "displayClock": "80'", - "period": 2, - "type": { - "id": "3", - "name": "STATUS_FINAL", - "state": "post", - "completed": true, - "description": "Final", - "detail": "Final", - "shortDetail": "Final" - } - }, - "competitors": [ - { - "id": "3", - "homeAway": "home", - "winner": true, - "score": "24", - "team": { - "id": "12", - "abbreviation": "MEL", - "displayName": "Melbourne Storm", - "shortDisplayName": "Storm", - "logo": "https://a.espncdn.com/i/teamlogos/rugby/teams/500/12.png" - }, - "records": [{ "name": "overall", "type": "total", "summary": "WWWWL" }] - }, - { - "id": "4", - "homeAway": "away", - "winner": false, - "score": "10", - "team": { - "id": "20", - "abbreviation": "SYD", - "displayName": "Sydney Roosters", - "shortDisplayName": "Roosters", - "logo": "https://a.espncdn.com/i/teamlogos/rugby/teams/500/20.png" - }, - "records": [{ "name": "overall", "type": "total", "summary": "LWLWL" }] - } - ] - } - ], - "status": { - "clock": 4800.0, - "displayClock": "80'", - "period": 2, - "type": { - "id": "3", - "name": "STATUS_FINAL", - "state": "post", - "completed": true - } - } - }, - { - "id": "401700003", - "date": "2026-07-12T09:00Z", - "name": "Penrith Panthers at Parramatta Eels", - "shortName": "PEN @ PAR", - "season": { "year": 2026, "type": 1 }, - "competitions": [ - { - "id": "401700003", - "date": "2026-07-12T09:00Z", - "status": { - "clock": 0.0, - "displayClock": "0'", - "period": 0, - "type": { - "id": "1", - "name": "STATUS_SCHEDULED", - "state": "pre", - "completed": false, - "description": "Scheduled", - "detail": "Sun, July 12th at 9:00 AM UTC", - "shortDetail": "7/12 - 9:00 AM UTC" - } - }, - "competitors": [ - { - "id": "5", - "homeAway": "home", - "winner": false, - "score": "0", - "team": { - "id": "14", - "abbreviation": "PAR", - "displayName": "Parramatta Eels", - "shortDisplayName": "Eels", - "logo": "https://a.espncdn.com/i/teamlogos/rugby/teams/500/14.png" - }, - "records": [{ "name": "overall", "type": "total", "summary": "LLWLW" }] - }, - { - "id": "6", - "homeAway": "away", - "winner": false, - "score": "0", - "team": { - "id": "18", - "abbreviation": "PEN", - "displayName": "Penrith Panthers", - "shortDisplayName": "Panthers", - "logo": "https://a.espncdn.com/i/teamlogos/rugby/teams/500/18.png" - }, - "records": [{ "name": "overall", "type": "total", "summary": "WWWLW" }] - } - ] - } - ], - "status": { - "clock": 0.0, - "displayClock": "0'", - "period": 0, - "type": { - "id": "1", - "name": "STATUS_SCHEDULED", - "state": "pre", - "completed": false - } - } - } - ] - } + "mock_data": "test/fixtures/mock.json" } diff --git a/plugins/nrl-scoreboard/test_nrl_plugin.py b/plugins/nrl-scoreboard/test_nrl_plugin.py index d0e71ee..184a028 100644 --- a/plugins/nrl-scoreboard/test_nrl_plugin.py +++ b/plugins/nrl-scoreboard/test_nrl_plugin.py @@ -11,9 +11,14 @@ import json import os +import sys import unittest +from unittest import mock HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) + +from dynamic_team_resolver import DynamicTeamResolver # noqa: E402 def _load(name): @@ -85,11 +90,15 @@ def test_slug_is_three_not_nrl(self): class TestHarness(unittest.TestCase): def test_harness_has_live_recent_upcoming(self): h = _load("test/harness.json") - events = h["mock_data"]["events"] + # mock_data is a path (relative to the plugin dir) to the fixture + # file, per the plugin safety harness convention - not inline data. + self.assertIsInstance(h["mock_data"], str) + mock = _load(h["mock_data"]) + events = mock["events"] states = {e["competitions"][0]["status"]["type"]["state"] for e in events} self.assertEqual(states, {"in", "post", "pre"}) # league slug in mock must be "3" - self.assertEqual(h["mock_data"]["leagues"][0]["slug"], "3") + self.assertEqual(mock["leagues"][0]["slug"], "3") class TestPeriodMapping(unittest.TestCase): @@ -122,5 +131,92 @@ def test_mappings(self): self.assertNotIn("Q", self.period_text("in", 1, clock="20'")) +def _mock_teams_response(): + """Minimal ESPN teams payload with a real collision: "NEW" is shared by + Newcastle Knights and New Zealand Warriors, "CAN" by Canberra Raiders and + Canterbury Bulldogs.""" + def team(team_id, abbr, display_name): + return { + "team": { + "id": team_id, + "abbreviation": abbr, + "displayName": display_name, + "shortDisplayName": display_name.split()[-1], + "name": display_name.split()[-1], + "location": " ".join(display_name.split()[:-1]), + } + } + + teams = [ + team("1", "NEW", "Newcastle Knights"), + team("2", "NEW", "New Zealand Warriors"), + team("3", "CAN", "Canberra Raiders"), + team("4", "CAN", "Canterbury Bulldogs"), + team("5", "BRI", "Brisbane Broncos"), + ] + return {"sports": [{"leagues": [{"teams": teams}]}]} + + +class TestDynamicTeamResolverAbbreviationCollisions(unittest.TestCase): + """Locks in the fix for the reported bug: NRL has duplicate abbreviations + ("NEW", "CAN") that must not be silently conflated. Favorite/exclude + matching must resolve to unique ESPN team IDs, not abbreviations.""" + + def setUp(self): + # Each test gets a clean class-level cache. + DynamicTeamResolver._teams_index_cache = {} + DynamicTeamResolver._teams_index_timestamp = {} + self.resolver = DynamicTeamResolver() + self.get_patcher = mock.patch( + "dynamic_team_resolver.requests.get", + return_value=mock.Mock( + status_code=200, + json=lambda: _mock_teams_response(), + raise_for_status=lambda: None, + ), + ) + self.get_patcher.start() + self.addCleanup(self.get_patcher.stop) + + def test_ambiguous_abbreviation_is_not_silently_resolved(self): + # "NEW" matches two teams - must not resolve to either one's ID. + resolved = self.resolver.resolve_teams(["NEW"], sport="nrl") + self.assertEqual(resolved, ["NEW"]) + self.assertNotIn(resolved[0], {"1", "2"}) + + resolved = self.resolver.resolve_teams(["CAN"], sport="nrl") + self.assertEqual(resolved, ["CAN"]) + self.assertNotIn(resolved[0], {"3", "4"}) + + def test_full_team_name_disambiguates(self): + self.assertEqual( + self.resolver.resolve_teams(["Newcastle Knights"], sport="nrl"), ["1"] + ) + self.assertEqual( + self.resolver.resolve_teams(["New Zealand Warriors"], sport="nrl"), ["2"] + ) + self.assertEqual( + self.resolver.resolve_teams(["Canberra Raiders"], sport="nrl"), ["3"] + ) + self.assertEqual( + self.resolver.resolve_teams(["Canterbury Bulldogs"], sport="nrl"), ["4"] + ) + + def test_unique_abbreviation_still_resolves(self): + self.assertEqual(self.resolver.resolve_teams(["BRI"], sport="nrl"), ["5"]) + + def test_numeric_team_id_passes_through(self): + self.assertEqual(self.resolver.resolve_teams(["5"], sport="nrl"), ["5"]) + + def test_fetch_failure_falls_back_to_raw_value(self): + DynamicTeamResolver._teams_index_cache = {} + DynamicTeamResolver._teams_index_timestamp = {} + with mock.patch( + "dynamic_team_resolver.requests.get", side_effect=Exception("boom") + ): + resolved = self.resolver.resolve_teams(["Brisbane Broncos"], sport="nrl") + self.assertEqual(resolved, ["Brisbane Broncos"]) + + if __name__ == "__main__": unittest.main(verbosity=2)