diff --git a/plugins.json b/plugins.json index 89761232..926231b4 100644 --- a/plugins.json +++ b/plugins.json @@ -73,10 +73,10 @@ "plugin_path": "plugins/baseball-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.22.1" + "latest_version": "1.22.2" }, { "id": "basketball-scoreboard", @@ -101,7 +101,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.10.2" + "latest_version": "1.10.3" }, { "id": "calendar", @@ -212,10 +212,10 @@ "plugin_path": "plugins/f1-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-08-01", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.7.2" + "latest_version": "1.7.3" }, { "id": "football-scoreboard", @@ -237,10 +237,10 @@ "plugin_path": "plugins/football-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "2.11.1" + "latest_version": "2.11.2" }, { "id": "geochron", @@ -335,7 +335,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.7.2", + "latest_version": "1.7.3", "icon": "fas fa-hockey-puck" }, { @@ -359,7 +359,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.7.2", + "latest_version": "1.7.3", "icon": "fas fa-baseball-ball" }, { @@ -460,10 +460,10 @@ "plugin_path": "plugins/march-madness", "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.0.3" + "latest_version": "1.0.4" }, { "id": "masters-tournament", @@ -484,10 +484,10 @@ "plugin_path": "plugins/masters-tournament", "stars": 0, "downloads": 0, - "last_updated": "2026-07-17", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "2.5.3" + "latest_version": "2.5.4" }, { "id": "mqtt-notifications", @@ -554,10 +554,10 @@ "plugin_path": "plugins/news", "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.3.0" + "latest_version": "1.3.1" }, { "id": "on-air", @@ -602,10 +602,10 @@ "plugin_path": "plugins/nfl-draft", "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.4.0", + "latest_version": "1.4.1", "icon": "fas fa-football-ball" }, { @@ -757,10 +757,10 @@ "plugin_path": "plugins/soccer-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "2.6.1" + "latest_version": "2.6.2" }, { "id": "static-image", @@ -902,10 +902,10 @@ "plugin_path": "plugins/ufc-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.3.2", + "latest_version": "1.3.3", "icon": "fas fa-fist-raised" }, { @@ -1048,8 +1048,8 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.3.1", - "last_updated": "2026-07-31" + "latest_version": "1.3.2", + "last_updated": "2026-08-05" }, { "id": "tidbyt-baseball-scoreboard", @@ -1092,10 +1092,10 @@ "plugin_path": "plugins/nrl-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.3.1" + "latest_version": "1.3.2" }, { "id": "jellyfin-now-playing", diff --git a/plugins/afl-scoreboard/data_sources.py b/plugins/afl-scoreboard/data_sources.py index d5ecb5d6..315d44f9 100644 --- a/plugins/afl-scoreboard/data_sources.py +++ b/plugins/afl-scoreboard/data_sources.py @@ -46,7 +46,7 @@ def fetch_standings(self, sport: str, league: str) -> Dict: def get_headers(self) -> Dict[str, str]: """Get headers for API requests.""" return { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index 40d32b25..bc3132fe 100644 --- a/plugins/afl-scoreboard/manifest.json +++ b/plugins/afl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "afl-scoreboard", "name": "AFL Scoreboard", - "version": "1.3.1", + "version": "1.3.2", "author": "ChuckBuilds", "description": "Live, recent, and upcoming AFL (Australian Football League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,12 @@ "afl_upcoming" ], "versions": [ + { + "version": "1.3.2", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.3.1", "released": "2026-08-05", @@ -70,7 +76,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/baseball-scoreboard/data_sources.py b/plugins/baseball-scoreboard/data_sources.py index 8fa8305e..b0d8cb3d 100644 --- a/plugins/baseball-scoreboard/data_sources.py +++ b/plugins/baseball-scoreboard/data_sources.py @@ -46,7 +46,7 @@ def fetch_standings(self, sport: str, league: str) -> Dict: def get_headers(self) -> Dict[str, str]: """Get headers for API requests.""" return { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/baseball-scoreboard/dynamic_team_resolver.py b/plugins/baseball-scoreboard/dynamic_team_resolver.py index cd7de5ed..9c060c81 100644 --- a/plugins/baseball-scoreboard/dynamic_team_resolver.py +++ b/plugins/baseball-scoreboard/dynamic_team_resolver.py @@ -142,7 +142,7 @@ def _fetch_rankings(self, sport: str) -> List[str]: url = f"https://site.api.espn.com/apis/site/v2/sports/{endpoint}" headers = { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/baseball-scoreboard/logo_manager.py b/plugins/baseball-scoreboard/logo_manager.py index 853d229a..10912f5d 100644 --- a/plugins/baseball-scoreboard/logo_manager.py +++ b/plugins/baseball-scoreboard/logo_manager.py @@ -294,7 +294,7 @@ def _download_headshot_image(self, url: str) -> Optional[Image.Image]: allowlisted by the caller.""" resp = requests.get( url, timeout=5, stream=True, - headers={"User-Agent": "LEDMatrix Baseball Plugin/1.0"}, + headers={"User-Agent": "LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)"}, ) try: resp.raise_for_status() diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index d1b79896..66998a04 100644 --- a/plugins/baseball-scoreboard/manifest.json +++ b/plugins/baseball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "baseball-scoreboard", "name": "Baseball Scoreboard", - "version": "1.22.1", + "version": "1.22.2", "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", "category": "sports", @@ -30,6 +30,12 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "version": "1.22.2", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.22.1", "released": "2026-08-05", @@ -315,7 +321,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/basketball-scoreboard/data_sources.py b/plugins/basketball-scoreboard/data_sources.py index d8589e4a..36032430 100644 --- a/plugins/basketball-scoreboard/data_sources.py +++ b/plugins/basketball-scoreboard/data_sources.py @@ -46,7 +46,7 @@ def fetch_standings(self, sport: str, league: str) -> Dict: def get_headers(self) -> Dict[str, str]: """Get headers for API requests.""" return { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/basketball-scoreboard/dynamic_team_resolver.py b/plugins/basketball-scoreboard/dynamic_team_resolver.py index f88755f7..eb14f803 100644 --- a/plugins/basketball-scoreboard/dynamic_team_resolver.py +++ b/plugins/basketball-scoreboard/dynamic_team_resolver.py @@ -138,7 +138,7 @@ def _fetch_rankings(self, sport: str) -> List[str]: url = f"https://site.api.espn.com/apis/site/v2/sports/{endpoint}" headers = { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index 73de8001..294f2164 100644 --- a/plugins/basketball-scoreboard/manifest.json +++ b/plugins/basketball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "basketball-scoreboard", "name": "Basketball Scoreboard", - "version": "1.10.2", + "version": "1.10.3", "description": "Live, recent, and upcoming basketball games across NBA, NCAA Men's, NCAA Women's, and WNBA with real-time scores, schedules, and March Madness tournament support", "author": "ChuckBuilds", "category": "sports", @@ -18,6 +18,12 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "version": "1.10.3", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.10.2", "released": "2026-08-05", diff --git a/plugins/f1-scoreboard/f1_data.py b/plugins/f1-scoreboard/f1_data.py index 5a294393..53bec0ff 100644 --- a/plugins/f1-scoreboard/f1_data.py +++ b/plugins/f1-scoreboard/f1_data.py @@ -52,7 +52,7 @@ def __init__(self, cache_manager=None, config: Dict[str, Any] = None): self.session.mount("https://", adapter) self.session.mount("http://", adapter) self.session.headers.update({ - "User-Agent": "LEDMatrix-F1/1.0", + "User-Agent": "LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)", "Accept": "application/json", }) diff --git a/plugins/f1-scoreboard/manifest.json b/plugins/f1-scoreboard/manifest.json index 8fe77182..fd43c9d1 100644 --- a/plugins/f1-scoreboard/manifest.json +++ b/plugins/f1-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "f1-scoreboard", "name": "F1 Scoreboard", - "version": "1.7.2", + "version": "1.7.3", "author": "ChuckBuilds", "class_name": "F1ScoreboardPlugin", "entry_point": "manager.py", @@ -29,6 +29,12 @@ "f1_calendar" ], "versions": [ + { + "version": "1.7.3", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-08-02", "version": "1.7.2", @@ -238,7 +244,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-08-01", + "last_updated": "2026-08-05", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/football-scoreboard/data_sources.py b/plugins/football-scoreboard/data_sources.py index e85ad631..2dadcb0d 100644 --- a/plugins/football-scoreboard/data_sources.py +++ b/plugins/football-scoreboard/data_sources.py @@ -46,7 +46,7 @@ def fetch_standings(self, sport: str, league: str) -> Dict: def get_headers(self) -> Dict[str, str]: """Get headers for API requests.""" return { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/football-scoreboard/dynamic_team_resolver.py b/plugins/football-scoreboard/dynamic_team_resolver.py index 9b26ffcd..61cb0596 100644 --- a/plugins/football-scoreboard/dynamic_team_resolver.py +++ b/plugins/football-scoreboard/dynamic_team_resolver.py @@ -141,7 +141,7 @@ def _fetch_rankings(self, sport: str) -> List[str]: url = f"https://site.api.espn.com/apis/site/v2/sports/{endpoint}" headers = { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index 1f5b52d6..9254aa87 100644 --- a/plugins/football-scoreboard/manifest.json +++ b/plugins/football-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "football-scoreboard", "name": "Football Scoreboard", - "version": "2.11.1", + "version": "2.11.2", "author": "ChuckBuilds", "class_name": "FootballScoreboardPlugin", "description": "Standalone plugin for live, recent, and upcoming football games across NFL and NCAA Football with real-time scores, down/distance, possession, and game status. Now with organized nested config!", @@ -24,6 +24,12 @@ "ncaa_fb_live" ], "versions": [ + { + "version": "2.11.2", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "2.11.1", "released": "2026-08-05", @@ -331,7 +337,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/hockey-scoreboard/data_sources.py b/plugins/hockey-scoreboard/data_sources.py index c9a5b237..acb54c12 100644 --- a/plugins/hockey-scoreboard/data_sources.py +++ b/plugins/hockey-scoreboard/data_sources.py @@ -46,7 +46,7 @@ def fetch_standings(self, sport: str, league: str) -> Dict: def get_headers(self) -> Dict[str, str]: """Get headers for API requests.""" return { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/hockey-scoreboard/dynamic_team_resolver.py b/plugins/hockey-scoreboard/dynamic_team_resolver.py index 6769c054..c0ff0fe5 100644 --- a/plugins/hockey-scoreboard/dynamic_team_resolver.py +++ b/plugins/hockey-scoreboard/dynamic_team_resolver.py @@ -150,7 +150,7 @@ def _fetch_rankings(self, sport: str) -> List[str]: url = f"https://site.api.espn.com/apis/site/v2/sports/{endpoint}" headers = { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index 50a778ca..cab5a1f8 100644 --- a/plugins/hockey-scoreboard/manifest.json +++ b/plugins/hockey-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "hockey-scoreboard", "name": "Hockey Scoreboard", - "version": "1.7.2", + "version": "1.7.3", "author": "ChuckBuilds", "description": "Live, recent, and upcoming hockey games across NHL, NCAA Men's, and NCAA Women's hockey with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hockey-scoreboard", @@ -54,6 +54,12 @@ } ], "versions": [ + { + "version": "1.7.3", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.7.2", "released": "2026-08-05", diff --git a/plugins/lacrosse-scoreboard/data_sources.py b/plugins/lacrosse-scoreboard/data_sources.py index c9a5b237..acb54c12 100644 --- a/plugins/lacrosse-scoreboard/data_sources.py +++ b/plugins/lacrosse-scoreboard/data_sources.py @@ -46,7 +46,7 @@ def fetch_standings(self, sport: str, league: str) -> Dict: def get_headers(self) -> Dict[str, str]: """Get headers for API requests.""" return { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/lacrosse-scoreboard/dynamic_team_resolver.py b/plugins/lacrosse-scoreboard/dynamic_team_resolver.py index 06567d54..f3e7d4cd 100644 --- a/plugins/lacrosse-scoreboard/dynamic_team_resolver.py +++ b/plugins/lacrosse-scoreboard/dynamic_team_resolver.py @@ -154,7 +154,7 @@ def _fetch_rankings(self, sport: str) -> List[str]: url = f"https://site.api.espn.com/apis/site/v2/sports/{endpoint}" headers = { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json index c6626b6e..0853526b 100644 --- a/plugins/lacrosse-scoreboard/manifest.json +++ b/plugins/lacrosse-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "lacrosse-scoreboard", "name": "Lacrosse Scoreboard", - "version": "1.7.2", + "version": "1.7.3", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NCAA men's and women's lacrosse games with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/lacrosse-scoreboard", @@ -50,6 +50,12 @@ } ], "versions": [ + { + "version": "1.7.3", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.7.2", "released": "2026-08-05", diff --git a/plugins/lacrosse-scoreboard/test_lacrosse_plugin.py b/plugins/lacrosse-scoreboard/test_lacrosse_plugin.py index 851f3c63..038deef6 100644 --- a/plugins/lacrosse-scoreboard/test_lacrosse_plugin.py +++ b/plugins/lacrosse-scoreboard/test_lacrosse_plugin.py @@ -131,7 +131,7 @@ class _NetworkUnavailable(Exception): def _fetch(url: str) -> dict: - req = urllib.request.Request(url, headers={"User-Agent": "LEDMatrix/1.0"}) + req = urllib.request.Request(url, headers={"User-Agent": "LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)"}) with urllib.request.urlopen(req, timeout=15) as resp: return json.loads(resp.read()) diff --git a/plugins/march-madness/manager.py b/plugins/march-madness/manager.py index a3d49685..971b2e18 100644 --- a/plugins/march-madness/manager.py +++ b/plugins/march-madness/manager.py @@ -143,7 +143,7 @@ def __init__( adapter = HTTPAdapter(max_retries=retry) self.session.mount("https://", adapter) self.session.mount("http://", adapter) - self.headers = {"User-Agent": "LEDMatrix/2.0"} + self.headers = {"User-Agent": "LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)"} # ScrollHelper if ScrollHelper: diff --git a/plugins/march-madness/manifest.json b/plugins/march-madness/manifest.json index 79c12f08..62ea6231 100644 --- a/plugins/march-madness/manifest.json +++ b/plugins/march-madness/manifest.json @@ -1,7 +1,7 @@ { "id": "march-madness", "name": "March Madness", - "version": "1.0.3", + "version": "1.0.4", "description": "NCAA March Madness tournament bracket tracker with round branding, seeded matchups, live scores, and upset highlighting", "author": "ChuckBuilds", "category": "sports", @@ -20,6 +20,12 @@ ">=2.0.0" ], "versions": [ + { + "version": "1.0.4", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.0.3", "released": "2026-07-31", @@ -45,7 +51,7 @@ ], "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", "display_modes": [ diff --git a/plugins/masters-tournament/download_assets.py b/plugins/masters-tournament/download_assets.py index d1cf997e..e82a6914 100644 --- a/plugins/masters-tournament/download_assets.py +++ b/plugins/masters-tournament/download_assets.py @@ -94,7 +94,7 @@ def download_player_headshots(): url = f"https://a.espncdn.com/combiner/i?img=/i/headshots/golf/players/full/{pid}.png&w=350&h=254" try: resp = requests.get(url, timeout=10, headers={ - "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" + "User-Agent": "LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)" }) resp.raise_for_status() img = Image.open(BytesIO(resp.content)).convert("RGBA") diff --git a/plugins/masters-tournament/logo_loader.py b/plugins/masters-tournament/logo_loader.py index 24196e70..d40946c5 100644 --- a/plugins/masters-tournament/logo_loader.py +++ b/plugins/masters-tournament/logo_loader.py @@ -190,7 +190,7 @@ def get_player_headshot(self, player_id: str, url: Optional[str], max_size: int if url: try: response = requests.get(url, timeout=5, headers={ - "User-Agent": "LEDMatrix Masters Plugin/2.0" + "User-Agent": "LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)" }) response.raise_for_status() diff --git a/plugins/masters-tournament/manifest.json b/plugins/masters-tournament/manifest.json index 95f2ade3..60f85be8 100644 --- a/plugins/masters-tournament/manifest.json +++ b/plugins/masters-tournament/manifest.json @@ -1,7 +1,7 @@ { "id": "masters-tournament", "name": "Masters Tournament", - "version": "2.5.3", + "version": "2.5.4", "description": "Broadcast-quality Masters Tournament display with real ESPN player headshots, accurate Augusta National hole layouts, fun facts, past champions, live leaderboards, and pixel-perfect LED matrix rendering", "author": "ChuckBuilds", "class_name": "MastersTournamentPlugin", @@ -43,6 +43,12 @@ "height": 64 }, "versions": [ + { + "version": "2.5.4", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-07-17", "version": "2.5.3", @@ -160,7 +166,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-07-17", + "last_updated": "2026-08-05", "compatible_versions": [ ">=2.0.0" ] diff --git a/plugins/masters-tournament/masters_data.py b/plugins/masters-tournament/masters_data.py index 9d311c70..ef0da1a0 100644 --- a/plugins/masters-tournament/masters_data.py +++ b/plugins/masters-tournament/masters_data.py @@ -40,7 +40,7 @@ class MastersDataSource: ATHLETE_URL = "https://site.web.api.espn.com/apis/common/v3/sports/golf/pga/athletes/{player_id}" ATHLETE_OVERVIEW_URL = "https://site.web.api.espn.com/apis/common/v3/sports/golf/pga/athletes/{player_id}/overview" - HTTP_HEADERS = {"User-Agent": "LEDMatrix Masters Plugin/2.1"} + HTTP_HEADERS = {"User-Agent": "LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)"} def __init__(self, cache_manager, config: Dict[str, Any]): self.cache_manager = cache_manager diff --git a/plugins/news/manager.py b/plugins/news/manager.py index 87af99fd..4d1701a8 100644 --- a/plugins/news/manager.py +++ b/plugins/news/manager.py @@ -922,7 +922,7 @@ def _fetch_feed_headlines(self, feed_name: str, feed_url: str) -> List[Dict]: try: self.logger.info(f"Fetching headlines from {feed_name}...") headers = { - 'User-Agent': 'LEDMatrix-NewsPlugin/1.0 (RSS Reader)' + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)' } response = requests.get(feed_url, timeout=self.background_config.get('request_timeout', 30), headers=headers) response.raise_for_status() diff --git a/plugins/news/manifest.json b/plugins/news/manifest.json index b592dbe4..aeed5db8 100644 --- a/plugins/news/manifest.json +++ b/plugins/news/manifest.json @@ -1,7 +1,7 @@ { "id": "news", "name": "News Ticker", - "version": "1.3.0", + "version": "1.3.1", "description": "Displays scrolling news headlines from RSS feeds including sports news from ESPN, NCAA updates, and custom RSS sources", "author": "ChuckBuilds", "category": "content", @@ -20,6 +20,12 @@ "branch": "main", "plugin_path": "plugins/news", "versions": [ + { + "version": "1.3.1", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.3.0", "released": "2026-07-31", @@ -82,7 +88,7 @@ ], "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", "display_modes": [ diff --git a/plugins/nfl-draft/manager.py b/plugins/nfl-draft/manager.py index 1c9a9d04..1f024be8 100644 --- a/plugins/nfl-draft/manager.py +++ b/plugins/nfl-draft/manager.py @@ -278,7 +278,7 @@ def _fetch_all_prospects(self) -> List[Dict[str, Any]]: self.logger.info(f"Fetching draft athletes list from {athletes_url}") - req = Request(athletes_url, headers={'User-Agent': 'Mozilla/5.0'}) + req = Request(athletes_url, headers={'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)'}) with urlopen(req, timeout=30) as response: data = json.loads(response.read().decode()) @@ -298,7 +298,7 @@ def _fetch_all_prospects(self) -> List[Dict[str, Any]]: # Fetch athlete details in parallel (limit concurrency) def fetch_athlete(url: str) -> Optional[Dict[str, Any]]: try: - req = Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + req = Request(url, headers={'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)'}) with urlopen(req, timeout=10) as response: return json.loads(response.read().decode()) except Exception as e: @@ -374,7 +374,7 @@ def _fetch_tankathon_mock_draft(self) -> List[Dict[str, Any]]: req = Request( self.TANKATHON_MOCK_DRAFT, headers={ - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "User-Agent": "LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9", "Referer": "https://www.tankathon.com/", @@ -487,7 +487,7 @@ def _fetch_nfl_teams(self) -> Dict[str, str]: teams: Dict[str, str] = {} try: url = "https://site.api.espn.com/apis/site/v2/sports/football/nfl/teams?limit=50" - req = Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + req = Request(url, headers={'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)'}) with urlopen(req, timeout=15) as response: data = json.loads(response.read().decode()) @@ -537,7 +537,7 @@ def _fetch_historical_picks(self) -> List[Dict[str, Any]]: f"https://sports.core.api.espn.com/v2/sports/football/leagues/nfl" f"/seasons/{year}/draft/rounds?lang=en®ion=us&limit=10" ) - req = Request(rounds_url, headers={'User-Agent': 'Mozilla/5.0'}) + req = Request(rounds_url, headers={'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)'}) with urlopen(req, timeout=15) as response: rounds_data = json.loads(response.read().decode()) @@ -559,7 +559,7 @@ def fetch_athlete_ref(url: str) -> Optional[Dict]: if not url: return None try: - req = Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + req = Request(url, headers={'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)'}) with urlopen(req, timeout=10) as response: return json.loads(response.read().decode()) except Exception as e: diff --git a/plugins/nfl-draft/manifest.json b/plugins/nfl-draft/manifest.json index e82989c3..520ef7bf 100644 --- a/plugins/nfl-draft/manifest.json +++ b/plugins/nfl-draft/manifest.json @@ -1,7 +1,7 @@ { "id": "nfl-draft", "name": "NFL Draft", - "version": "1.4.0", + "version": "1.4.1", "author": "ChuckBuilds", "description": "Displays projected NFL draft picks from ESPN with live draft tracking support during the annual NFL Draft event. Includes simulate_live mode to replay a completed draft using real ESPN core API data. Shows team logos, player names, positions, and pick numbers in a scrolling display.", "entry_point": "manager.py", @@ -24,6 +24,12 @@ ], "icon": "fas fa-football-ball", "versions": [ + { + "version": "1.4.1", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.4.0", "released": "2026-07-31", @@ -112,5 +118,5 @@ "nfl_logos": "assets/sports/nfl_logos/" }, "license": "GPL-3.0", - "last_updated": "2026-07-31" + "last_updated": "2026-08-05" } diff --git a/plugins/nrl-scoreboard/data_sources.py b/plugins/nrl-scoreboard/data_sources.py index 38d6afb2..3966db90 100644 --- a/plugins/nrl-scoreboard/data_sources.py +++ b/plugins/nrl-scoreboard/data_sources.py @@ -46,7 +46,7 @@ def fetch_standings(self, sport: str, league: str) -> Dict: def get_headers(self) -> Dict[str, str]: """Get headers for API requests.""" return { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index 2894b6fe..ccd5f6d0 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.3.1", + "version": "1.3.2", "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": [ + { + "version": "1.3.2", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.3.1", "released": "2026-08-05", @@ -82,7 +88,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/soccer-scoreboard/data_sources.py b/plugins/soccer-scoreboard/data_sources.py index 720f8b78..a24b9541 100644 --- a/plugins/soccer-scoreboard/data_sources.py +++ b/plugins/soccer-scoreboard/data_sources.py @@ -46,7 +46,7 @@ def fetch_standings(self, sport: str, league: str) -> Dict: def get_headers(self) -> Dict[str, str]: """Get headers for API requests.""" return { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index c82593b8..93e128b9 100644 --- a/plugins/soccer-scoreboard/manifest.json +++ b/plugins/soccer-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "soccer-scoreboard", "name": "Soccer Scoreboard", - "version": "2.6.1", + "version": "2.6.2", "author": "ChuckBuilds", "description": "Live, recent, and upcoming soccer games across multiple leagues including Premier League, La Liga, Bundesliga, Serie A, Ligue 1, MLS, Liga Portugal, Champions League, Europa League, and FIFA World Cup", "category": "sports", @@ -26,6 +26,12 @@ "soccer_upcoming" ], "versions": [ + { + "version": "2.6.2", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "2.6.1", "released": "2026-08-05", @@ -170,7 +176,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/ufc-scoreboard/data_sources.py b/plugins/ufc-scoreboard/data_sources.py index 6ec73041..5eec7c96 100644 --- a/plugins/ufc-scoreboard/data_sources.py +++ b/plugins/ufc-scoreboard/data_sources.py @@ -47,7 +47,7 @@ def fetch_standings(self, sport: str, league: str) -> Dict: def get_headers(self) -> Dict[str, str]: """Get headers for API requests.""" return { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/ufc-scoreboard/headshot_downloader.py b/plugins/ufc-scoreboard/headshot_downloader.py index 9a2de14a..bb69230a 100644 --- a/plugins/ufc-scoreboard/headshot_downloader.py +++ b/plugins/ufc-scoreboard/headshot_downloader.py @@ -41,7 +41,7 @@ def __init__(self, request_timeout: int = 30, retry_attempts: int = 3): # Set up headers self.headers = { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'image/png,image/jpeg,image/*', 'Accept-Language': 'en-US,en;q=0.9', 'Accept-Encoding': 'gzip, deflate, br', diff --git a/plugins/ufc-scoreboard/manifest.json b/plugins/ufc-scoreboard/manifest.json index 14206aca..19a03669 100644 --- a/plugins/ufc-scoreboard/manifest.json +++ b/plugins/ufc-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "ufc-scoreboard", "name": "UFC Scoreboard", - "version": "1.3.2", + "version": "1.3.3", "author": "LegoGuy1000", "contributors": [ { @@ -32,6 +32,12 @@ "default_duration": 15, "config_schema": "config_schema.json", "versions": [ + { + "version": "1.3.3", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-08-02", "version": "1.3.2", @@ -95,7 +101,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "stars": 0, "downloads": 0, "verified": true, diff --git a/scripts/check_espn_api.py b/scripts/check_espn_api.py new file mode 100755 index 00000000..5318813c --- /dev/null +++ b/scripts/check_espn_api.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +""" +Probe every shape of ESPN request this repo makes, and say what is actually wrong. + +ESPN has no public API. Every endpoint the scoreboards use is undocumented and +can change without notice, so "the ESPN plugins went blank" is a report we get +periodically and can rarely act on: it arrives as a symptom, and the interesting +question — *which* part broke — needs a machine that can reach ESPN to answer. +This script is that answer. Run it from a host with normal internet access (a +Pi running LEDMatrix, a laptop) and it reports, per endpoint, whether ESPN still +serves what the plugins expect. + +The important trick is the second axis: each endpoint is tried under several +``User-Agent`` values, because anti-bot filtering is the most common way an +undocumented API "changes" and it discriminates on exactly that header. + +Do not assume which side of that filter is the safe one. This script originally +tested for the familiar shape — a browser agent works, a script agent is +refused — and reported "healthy" all the way through the 2026-08-04 outage, +where ESPN had inverted it: + +* a browser string is refused unconditionally (no other header rescues it), +* a bare custom token like ``LEDMatrix/1.0`` is refused when the request also + sends no ``Accept`` header, +* honest client tokens (``python-requests``, ``curl``, ``Python-urllib``) and a + token carrying a project URL are accepted. + +So the profiles below are split into what the plugins actually send (``shipped``) +and controls kept only to characterise the filter, and the verdict is +direction-agnostic — it names which agents were accepted and which refused +rather than assuming. The diagnosis: + +* every profile fails -> ESPN moved or withdrew the endpoint; the URL needs work. +* some accepted, some refused -> ESPN is filtering on User-Agent. It only breaks + the plugins if a *shipped* profile is on the refused side; the fix is then a + header change, not a URL change. +* everything passes -> ESPN is fine; look at the plugin, the cache, or the + network in front of it. + +Usage:: + + python scripts/check_espn_api.py # probe, non-zero exit if broken + python scripts/check_espn_api.py --verbose # show every profile's result + python scripts/check_espn_api.py --json # machine-readable report + python scripts/check_espn_api.py --timeout 30 +""" + +import argparse +import datetime +import json +import ssl +import sys +import urllib.error +import urllib.request + +# The distinct endpoint families the plugins call. Each entry is +# (label, url, key that must be present and non-empty in the JSON response). +# +# "expect" is what makes this a contract test rather than a ping: ESPN can return +# a cheerful 200 whose body no longer holds the field the renderer reads, and a +# status-code-only check would call that healthy. +TODAY = datetime.date.today().strftime("%Y%m%d") + +ENDPOINTS = [ + ( + "site.api scoreboard (MLB)", + "https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/scoreboard", + "events", + ), + ( + "site.api scoreboard w/ dates (NFL)", + "https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard" + f"?dates={TODAY}&limit=1000", + "leagues", + ), + ( + "site.api scoreboard (NCAA FB, groups)", + "https://site.api.espn.com/apis/site/v2/sports/football/college-football/scoreboard" + "?groups=80&limit=1000", + "leagues", + ), + ( + "site.api teams (NHL)", + "https://site.api.espn.com/apis/site/v2/sports/hockey/nhl/teams?limit=1000", + "sports", + ), + ( + "site.api rankings (NCAA FB)", + "https://site.api.espn.com/apis/site/v2/sports/football/college-football/rankings", + "rankings", + ), + ( + "site.api standings v2 (NBA)", + "https://site.api.espn.com/apis/v2/sports/basketball/nba/standings", + None, + ), + ( + "site.web.api scoreboard header (cricket)", + "https://site.web.api.espn.com/apis/v2/scoreboard/header?sport=cricket", + "sports", + ), + ( + "site.web.api common v3 (golf athlete)", + "https://site.web.api.espn.com/apis/common/v3/sports/golf/pga/athletes/9478", + None, + ), + ( + "sports.core.api league (NFL)", + "https://sports.core.api.espn.com/v2/sports/football/leagues/nfl", + None, + ), + ( + "site.api soccer scoreboard (EPL)", + "https://site.api.espn.com/apis/site/v2/sports/soccer/eng.1/scoreboard", + "leagues", + ), +] + +# The voices to try. `shipped` marks the ones the plugins actually send, which +# is what decides the exit code: a control failing is information, a shipped +# profile failing is the outage. The controls are kept because *which* agents +# ESPN rejects is the diagnosis — on 2026-08-04 it started refusing browser +# strings and bare custom tokens while accepting honest client tokens, the +# reverse of the anti-bot filtering this script was first written to expect. +PROFILES = [ + ("default", {}, True), + ( + "shipped", + { + "User-Agent": "LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)", + "Accept": "application/json", + }, + True, + ), + ( + "bare-custom", + {"User-Agent": "LEDMatrix/1.0", "Accept": "application/json"}, + False, + ), + ( + "browser", + { + "User-Agent": ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" + ), + "Accept": "application/json, text/plain, */*", + }, + False, + ), +] + +SHIPPED = [name for name, _headers, shipped in PROFILES if shipped] + + +def probe(url, headers, timeout): + """Fetch url and return a result dict. Never raises.""" + # The URLs come from the table above, but pin the scheme rather than trusting + # them: urlopen would honour file:// or a custom scheme if an entry ever + # arrived from somewhere less trustworthy. Reported as an ordinary failure so + # the promise above holds and one bad row cannot abort the whole run. + if not url.startswith("https://"): + return {"ok": False, "status": None, "error": f"refusing non-HTTPS URL: {url!r}"} + + request = urllib.request.Request(url) + for name, value in headers.items(): + request.add_header(name, value) + + try: + # B310 is a syntactic blacklist rule and fires on the call regardless of + # the scheme guard above, which is what actually makes this safe. + with urllib.request.urlopen(request, timeout=timeout) as response: # nosec B310 + body = response.read() + status = response.getcode() + except urllib.error.HTTPError as exc: + return {"ok": False, "status": exc.code, "error": f"HTTP {exc.code} {exc.reason}"} + except urllib.error.URLError as exc: + return {"ok": False, "status": None, "error": f"unreachable: {exc.reason}"} + except (ssl.SSLError, OSError) as exc: + return {"ok": False, "status": None, "error": f"connection: {exc}"} + + try: + data = json.loads(body) + except (ValueError, UnicodeDecodeError): + head = body[:80].decode("utf-8", "replace").replace("\n", " ") + return { + "ok": False, + "status": status, + "error": f"not JSON (got {len(body)}B starting {head!r})", + } + + return {"ok": True, "status": status, "data": data, "bytes": len(body)} + + +def check_expected_key(result, expected): + """Fold the response-shape contract into the result dict.""" + if not result["ok"] or expected is None: + return result + + data = result.get("data") + if not isinstance(data, dict) or expected not in data: + result["ok"] = False + result["error"] = f"200 but no {expected!r} key (shape changed)" + elif not data[expected]: + # An empty events list is normal on a day with no games, so this is a + # note rather than a failure — flagging it would cry wolf every off-day. + result["note"] = f"{expected!r} present but empty" + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[1]) + parser.add_argument("--timeout", type=float, default=20.0, help="per-request timeout") + parser.add_argument("--verbose", action="store_true", help="show every profile") + parser.add_argument("--json", action="store_true", dest="as_json") + args = parser.parse_args() + + report = [] + for label, url, expected in ENDPOINTS: + row = {"endpoint": label, "url": url, "profiles": {}} + for profile_name, headers, _shipped in PROFILES: + result = check_expected_key(probe(url, headers, args.timeout), expected) + row["profiles"][profile_name] = { + k: v for k, v in result.items() if k != "data" + } + report.append(row) + + if args.as_json: + print(json.dumps(report, indent=2)) + else: + render(report, args.verbose) + + return summarize(report, args.as_json) + + +def render(report, verbose): + print(f"ESPN endpoint probe — {datetime.datetime.now():%Y-%m-%d %H:%M:%S}\n") + for row in report: + states = row["profiles"] + worked = [name for name, r in states.items() if r["ok"]] + mark = "PASS" if len(worked) == len(states) else ("PART" if worked else "FAIL") + print(f"[{mark}] {row['endpoint']}") + + for name, result in states.items(): + if result["ok"] and not verbose: + if result.get("note"): + print(f" {name}: ok — {result['note']}") + continue + if result["ok"]: + detail = result.get("note") or f"{result.get('bytes', 0)}B" + print(f" {name}: ok — {detail}") + else: + print(f" {name}: {result['error']}") + if mark != "PASS": + print(f" {row['url']}") + print() + + +def summarize(report, as_json): + """Print the diagnosis and pick an exit code.""" + total = len(report) + failed = [r for r in report if not any(p["ok"] for p in r["profiles"].values())] + + # A request that never got an HTTP status never reached ESPN, and says nothing + # about ESPN. Keep it apart from a genuine ESPN error: a DNS failure, a proxy + # that denies the host, or a captive portal all look like "everything is down" + # while ESPN is perfectly healthy, and reporting that as a withdrawn endpoint + # sends whoever runs this off rewriting URLs that were never broken. + unreachable = [ + r + for r in failed + if all(p.get("status") is None for p in r["profiles"].values()) + ] + all_dead = [r for r in failed if r not in unreachable] + + # Direction-agnostic on purpose. The first version of this asked only + # "does browser work where ours does not", so when ESPN inverted the rule + # and began rejecting browser strings instead, every endpoint looked + # healthy and the advice it printed — send a browser agent — was the exact + # change that would have kept the plugins broken. + ua_split = [ + r for r in report + if any(p["ok"] for p in r["profiles"].values()) + and not all(p["ok"] for p in r["profiles"].values()) + ] + # Only a shipped profile failing actually breaks a plugin. + broken_shipped = [ + r for r in report + if r not in unreachable and not all(r["profiles"][n]["ok"] for n in SHIPPED) + ] + + if as_json: + return 1 if failed or broken_shipped else 0 + + if unreachable and len(unreachable) == total: + reason = next(iter(unreachable[0]["profiles"].values()))["error"] + print(f"CANNOT REACH ESPN: all {total} endpoints failed without ever getting " + "an HTTP response.") + print(f" First reason: {reason}") + print(" This is a problem between this machine and ESPN — DNS, a proxy that " + "denies the host, or no route out — not an ESPN API change. Re-run from " + "a host with normal internet access before concluding anything about ESPN.") + return 2 + + if not failed and not ua_split: + print(f"All {total} endpoints healthy under every User-Agent. ESPN is not " + "the problem — look at the plugin, its cache, or the local network.") + return 0 + + if ua_split: + # Name the agents rather than assuming which side of the split is ours. + accepted, rejected = set(), set() + for row in ua_split: + for name, result in row["profiles"].items(): + (accepted if result["ok"] else rejected).add(name) + + print(f"USER-AGENT FILTERING: {len(ua_split)}/{total} endpoints accept some " + "agents and reject others.") + print(f" accepted: {', '.join(sorted(accepted)) or 'none'}") + print(f" rejected: {', '.join(sorted(rejected)) or 'none'}") + + broken_names = sorted(n for n in SHIPPED if n in rejected) + if broken_names: + print(f" The plugins send {', '.join(broken_names)}, which ESPN is now " + "rejecting. This is a header change, not a URL change: switch every " + "ESPN caller to an agent in the accepted list above.") + else: + print(" Every agent the plugins actually send is still accepted, so this " + "does not break them today. It is a warning: ESPN is discriminating " + "on User-Agent, and which side is allowed has flipped before.") + for row in ua_split: + missing = sorted(n for n, r in row["profiles"].items() if not r["ok"]) + print(f" - {row['endpoint']}: rejects {', '.join(missing)}") + + if all_dead: + print(f"\nENDPOINT DOWN: {len(all_dead)}/{total} endpoints fail under every " + "agent, browser included.") + print(" Not a header problem — the URL moved or was withdrawn, or the " + "response shape changed. These need per-endpoint work:") + for row in all_dead: + reason = next(iter(row["profiles"].values()))["error"] + print(f" - {row['endpoint']}: {reason}") + + if unreachable: + print(f"\nUNREACHABLE: {len(unreachable)}/{total} endpoints never got an HTTP " + "response, so they are undiagnosed rather than broken:") + for row in unreachable: + reason = next(iter(row["profiles"].values()))["error"] + print(f" - {row['endpoint']}: {reason}") + + # A split that leaves every shipped agent working is a warning, not a + # failure: nothing is broken today, and exiting non-zero for it would train + # whoever runs this to ignore the one exit code that means "act now". + if not failed and not broken_shipped: + print("\nNothing the plugins send is being rejected — reporting the split " + "above as a warning, not a failure.") + return 0 + return 1 + + +if __name__ == "__main__": + sys.exit(main())