fix(espn): send an identifying User-Agent so ESPN stops returning 403 - #254
Conversation
📝 WalkthroughWalkthroughThe change standardizes identifying ESPN request headers across plugins, adds an ESPN API diagnostic command, and updates plugin versions, release notes, and catalog metadata. ChangesESPN request identification
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant CLI
participant Diagnostic
participant ESPN
participant Report
CLI->>Diagnostic: Run check_espn_api.py
Diagnostic->>ESPN: Probe endpoints with request profiles
ESPN-->>Diagnostic: Return response or connection error
Diagnostic->>Report: Validate response keys and classify results
Report-->>CLI: Print report and return status
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 73 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
"The ESPN plugins went blank" arrives as a symptom and rarely as a diagnosis. The plugins call ESPN's undocumented API from ~15 places and, critically, do not speak with one voice: some callers send LEDMatrix/1.0, many send nothing at all and get the stdlib/requests default, and a few already send a browser string. Anti-bot filtering discriminates on exactly that header, so the same outage can hit some plugins and spare others, which makes the reports hard to read. check_espn_api.py probes each endpoint family under all three User-Agent profiles and turns the result into a diagnosis: a browser-works/ours-fail split means header filtering and a header fix; failure under every agent means the URL moved or the response shape changed. It also separates "never got an HTTP response" from "ESPN returned an error". A denied proxy or a DNS failure otherwise looks identical to a withdrawn endpoint, and sends whoever runs it off rewriting URLs that were never broken. That case now exits 2 and says so. Endpoint checks assert an expected key rather than a bare 200, since ESPN can serve a cheerful 200 whose body no longer holds the field the renderer reads. An empty events list stays a note, not a failure, so it does not cry wolf on an off-day. No plugin code or versions touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017mSAZ7o55QKTP8KQm3cv5c
Codacy flagged the urlopen call (bandit B310, medium): urlopen honours file:// and custom schemes, so a URL reaching it unchecked is a path traversal waiting for a careless edit. The endpoints here are module constants today, but probe() is generic and the next person to add a row gets the guard for free. Follows the convention already set in check_team_pickers.py, with one difference: probe() documents that it never raises, so a bad scheme comes back as an ordinary failure result rather than an exception that would abort the remaining probes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017mSAZ7o55QKTP8KQm3cv5c
Around 11:00 EDT on 2026-08-04 ESPN's site.api began rejecting the agents
these plugins send, and every ESPN-backed scoreboard started returning
`403 Client Error: Forbidden`. A device that had been running fine logged
287 ESPN errors in a day.
The filter is not the familiar one. Probing site.api across agents, using
requests as the plugins do:
bare 'LEDMatrix/1.0' 403 (with or without Accept)
browser string 403 (no header rescues it)
'LEDMatrix/1.0 (+https://github.com/...)' 200
requests / urllib / curl defaults 200
ESPN rejects browser-style strings outright and bare custom tokens, and
accepts honest client tokens or an agent that identifies the client and
links to it. The instinct to "just send a browser User-Agent" is now
exactly backwards — that is the one thing guaranteed to stay blocked.
Sweeping every User-Agent literal in the repo and probing each turned up
ten blocked strings across 24 files in 14 plugins: the `LEDMatrix/1.0`
family in the data_sources/dynamic_team_resolver files, per-plugin tokens
like `LEDMatrix-F1/1.0` and `LEDMatrix Masters Plugin/2.1`, and browser
strings in nfl-draft and masters-tournament. All now send one agent
carrying the project URL — one string, so the next ESPN change is one
grep rather than ten.
Callers that reach other services are deliberately untouched:
ledmatrix-flights, ledmatrix-stocks and stock-news send a browser agent
to hosts ESPN's change never involved.
Also fixes the probe this branch added, which reported "ESPN is not the
problem" throughout the outage. It only tested for the old shape —
browser works, ours does not — so an inverted filter read as healthy, and
the fix it printed (send a browser agent) was the change that would have
kept everything broken. The verdict is direction-agnostic now, names
which agents were accepted and refused rather than assuming, and
separates what the plugins ship from controls kept to characterise the
filter.
basketball, hockey and lacrosse skip a patch number so this cannot
collide with the versions PR #252 already claims.
Verified on a live device: ESPN errors went from a steady stream to zero
across a restart, with live MLB games fetching again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
41532b1 to
f8251fd
Compare
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
scripts/check_espn_api.py (1)
128-132: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winMatch the shipped request-header profiles.
shippedsendsAccept: application/json, butplugins/march-madness/manager.py:146,plugins/nfl-draft/manager.py:281, andplugins/masters-tournament/masters_data.py:43send only theUser-Agent. Add separate profiles that match actual shippedrequestsandurllibdefaults, or remove the explicitAcceptheader where callers do not send it.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@scripts/check_espn_api.py` around lines 128 - 132, Update the shipped request-header profile definitions in the ESPN API checker so they match the actual callers: remove the explicit Accept header from the profile used by requests that send only User-Agent, or add distinct profiles for callers with different requests/urllib defaults. Ensure each profile accurately reflects the headers sent by the referenced plugin managers and masters data loader.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@plugins/basketball-scoreboard/data_sources.py`:
- Line 49: Scope the shared User-Agent to ESPN requests only: in
plugins/basketball-scoreboard/data_sources.py at lines 49-49, update
SoccerAPIDataSource.get_headers() and MLBAPIDataSource usage so non-ESPN
requests do not inherit it; in plugins/f1-scoreboard/f1_data.py at lines 55-55,
update _fetch_json() to apply the header only for ESPN calls or use separate
service-specific sessions, preserving appropriate headers for Jolpi and OpenF1.
In `@plugins/news/manager.py`:
- Line 925: In plugins/news/manager.py:925-925, update the request-header
selection to use the ESPN-specific User-Agent only for ESPN feed hosts, while
preserving the previous header for Google News, Covering the Corner, and custom
feeds. In plugins/nfl-draft/manager.py:377-377, restore the prior Tankathon
request header unless Tankathon is explicitly intended to be included in the
supported ESPN-scoped change.
In `@scripts/check_espn_api.py`:
- Around line 156-172: Update probe to use a custom urllib redirect handler that
validates each redirect target with is_safe_redirect_url(...) and rejects any
non-HTTPS destination before following it. Preserve the existing initial URL
scheme check, request headers, timeout, and never-raises failure behavior while
ensuring the handler is used by the urlopen call.
---
Nitpick comments:
In `@scripts/check_espn_api.py`:
- Around line 128-132: Update the shipped request-header profile definitions in
the ESPN API checker so they match the actual callers: remove the explicit
Accept header from the profile used by requests that send only User-Agent, or
add distinct profiles for callers with different requests/urllib defaults.
Ensure each profile accurately reflects the headers sent by the referenced
plugin managers and masters data loader.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 9b62aeb8-d813-4ed0-baa9-65f3e992dbb6
📒 Files selected for processing (40)
plugins.jsonplugins/afl-scoreboard/data_sources.pyplugins/afl-scoreboard/manifest.jsonplugins/baseball-scoreboard/data_sources.pyplugins/baseball-scoreboard/dynamic_team_resolver.pyplugins/baseball-scoreboard/logo_manager.pyplugins/baseball-scoreboard/manifest.jsonplugins/basketball-scoreboard/data_sources.pyplugins/basketball-scoreboard/dynamic_team_resolver.pyplugins/basketball-scoreboard/manifest.jsonplugins/f1-scoreboard/f1_data.pyplugins/f1-scoreboard/manifest.jsonplugins/football-scoreboard/data_sources.pyplugins/football-scoreboard/dynamic_team_resolver.pyplugins/football-scoreboard/manifest.jsonplugins/hockey-scoreboard/data_sources.pyplugins/hockey-scoreboard/dynamic_team_resolver.pyplugins/hockey-scoreboard/manifest.jsonplugins/lacrosse-scoreboard/data_sources.pyplugins/lacrosse-scoreboard/dynamic_team_resolver.pyplugins/lacrosse-scoreboard/manifest.jsonplugins/lacrosse-scoreboard/test_lacrosse_plugin.pyplugins/march-madness/manager.pyplugins/march-madness/manifest.jsonplugins/masters-tournament/download_assets.pyplugins/masters-tournament/logo_loader.pyplugins/masters-tournament/manifest.jsonplugins/masters-tournament/masters_data.pyplugins/news/manager.pyplugins/news/manifest.jsonplugins/nfl-draft/manager.pyplugins/nfl-draft/manifest.jsonplugins/nrl-scoreboard/data_sources.pyplugins/nrl-scoreboard/manifest.jsonplugins/soccer-scoreboard/data_sources.pyplugins/soccer-scoreboard/manifest.jsonplugins/ufc-scoreboard/data_sources.pyplugins/ufc-scoreboard/headshot_downloader.pyplugins/ufc-scoreboard/manifest.jsonscripts/check_espn_api.py
| """Get headers for API requests.""" | ||
| return { | ||
| 'User-Agent': 'LEDMatrix/1.0', | ||
| 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Files:"
git ls-files | rg '(^|/)plugins/(basketball-scoreboard|f1-scoreboard)/(data_sources|f1_data)\.py$' || true
echo
echo "basketball data_sources outline:"
ast-grep outline plugins/basketball-scoreboard/data_sources.py --view expanded || true
echo
echo "basketball relevant lines:"
cat -n plugins/basketball-scoreboard/data_sources.py | sed -n '1,160p'
echo
echo "f1 f1_data outline:"
ast-grep outline plugins/f1-scoreboard/f1_data.py --view expanded || true
echo
echo "f1 relevant lines:"
cat -n plugins/f1-scoreboard/f1_data.py | sed -n '1,180p'Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 19333
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "soccer class and call sites:"
cat -n plugins/basketball-scoreboard/data_sources.py | sed -n '160,340p'
echo
echo "F1 requests using _fetch_json / bases:"
python3 - <<'PY'
from pathlib import Path
p = Path('plugins/f1-scoreboard/f1_data.py')
s = p.read_text()
bases = ['ESPN_BASE', 'JOLPI_BASE', 'OPENF1_BASE']
for i,line in enumerate(s.splitlines(),1):
if '_fetch_json(' in line or any(b in line for b in bases):
print(f'{i}: {line}')
PY
echo
echo "rest of F1 relevant _fetch_json usage:"
cat -n plugins/f1-scoreboard/f1_data.py | sed -n '108,90p' | sed -n '1,80p'
cat -n plugins/f1-scoreboard/f1_data.py | sed -n '138,750p' | grep -n "_fetch_json\|ESPN_BASE\|JOLPI_BASE\|OPENF1_BASE" || trueRepository: ChuckBuilds/ledmatrix-plugins
Length of output: 10109
Scope the new User-Agent per service.
Both locations send the shared User-Agent beyond ESPN calls.
plugins/basketball-scoreboard/data_sources.py:SoccerAPIDataSource.get_headers()callssuper().get_headers()and also usesMLBAPIDataSource, so keep this value only for ESPN requests or override it in the non-ESPN classes.plugins/f1-scoreboard/f1_data.py:_fetch_json()is used by Jolpi and OpenF1 callers too, so override headers only for ESPN or use separate service sessions.
📍 Affects 2 files
plugins/basketball-scoreboard/data_sources.py#L49-L49(this comment)plugins/f1-scoreboard/f1_data.py#L55-L55
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@plugins/basketball-scoreboard/data_sources.py` at line 49, Scope the shared
User-Agent to ESPN requests only: in
plugins/basketball-scoreboard/data_sources.py at lines 49-49, update
SoccerAPIDataSource.get_headers() and MLBAPIDataSource usage so non-ESPN
requests do not inherit it; in plugins/f1-scoreboard/f1_data.py at lines 55-55,
update _fetch_json() to apply the header only for ESPN calls or use separate
service-specific sessions, preserving appropriate headers for Jolpi and OpenF1.
| 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)' |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Keep non-ESPN User-Agent values unchanged.
The PR objective limits this change to ESPN callers. These requests target other services and now receive the ESPN-specific header.
plugins/news/manager.py#L925-L925: select the identifying header only for ESPN feed hosts. Preserve the prior header for Google News, Covering the Corner, and custom feeds.plugins/nfl-draft/manager.py#L377-L377: restore the prior Tankathon request header, or explicitly include Tankathon in the supported change scope.
📍 Affects 2 files
plugins/news/manager.py#L925-L925(this comment)plugins/nfl-draft/manager.py#L377-L377
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@plugins/news/manager.py` at line 925, In plugins/news/manager.py:925-925,
update the request-header selection to use the ESPN-specific User-Agent only for
ESPN feed hosts, while preserving the previous header for Google News, Covering
the Corner, and custom feeds. In plugins/nfl-draft/manager.py:377-377, restore
the prior Tankathon request header unless Tankathon is explicitly intended to be
included in the supported ESPN-scoped change.
| 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 |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== git status/stat =="
git status --short
git diff --stat || true
echo "== file outline/size =="
wc -l scripts/check_espn_api.py
ast-grep outline scripts/check_espn_api.py --view compact || true
echo "== relevant source =="
sed -n '120,190p' scripts/check_espn_api.py
echo "== full script symbols/usages of probe/urllib =="
rg -n "def probe|urlopen|urllib\.request|redirect|https://|http://" scripts/check_espn_api.py
echo "== Python urllib redirect behavior probe if available =="
python3 - <<'PY'
import urllib.request
import tempfile, os, http.server, socketserver, threading, time
port = 0
srv = None
class RedirectHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/redirect":
self.send_response(302)
self.send_header("Location", "http://httpbin.invalid/http-redirect-host")
self.end_headers()
else:
# No handler for redirected GET; urllib should make a GET and then fail.
self.send_response(200)
self.end_headers()
self.wfile.write(b"origin")
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "simple.html")
with open(path, "wb") as f:
f.write(b"<html></html>")
# urllib follows FileRedirectHandler in Python standard library for file://;
# show that non-HTTPS initial URL guard can be bypassed if caller did not check scheme.
req = urllib.request.Request("file://" + path)
try:
with urllib.request.urlopen(req, timeout=2) as r:
print("file_protocol:", r.url, r.status)
except Exception as e:
print("file_exception:", type(e).__name__, str(e))
srv = http.server.HTTPServer(("127.0.0.1", port), RedirectHandler)
port = srv.server_address[1]
t = threading.Thread(target=srv.serve_forever, daemon=True)
t.start()
time.sleep(0.2)
try:
req = urllib.request.Request(f"http://127.0.0.1:{port}/redirect")
with urllib.request.urlopen(req, timeout=2) as r:
print("protocol:", r.url, r.status)
except Exception as e:
print("redirect_exception:", type(e).__name__, str(e), getattr(e, "reason", None))
finally:
srv.shutdown()
srv.server_close()
PYRepository: ChuckBuilds/ledmatrix-plugins
Length of output: 5993
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== git status/stat =="
git status --short || true
git diff --stat || true
echo "== file outline/size =="
wc -l scripts/check_espn_api.py || true
ast-grep outline scripts/check_espn_api.py --view compact || true
echo "== relevant source =="
sed -n '120,190p' scripts/check_espn_api.py || true
echo "== full script symbols/usages of probe/urllib =="
rg -n "def probe|urlopen|urllib\.request|redirect|https://|http://" scripts/check_espn_api.py || true
echo "== Python urllib redirect behavior probe if available =="
python3 - <<'PY'
import urllib.request
import tempfile, os, http.server, socketserver, threading, time
class RedirectHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/redirect":
self.send_response(302)
self.send_header("Location", "http://httpbin.invalid/http-redirect-host")
self.end_headers()
else:
self.send_response(200)
self.end_headers()
self.wfile.write(b"origin")
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "simple.html")
with open(path, "wb") as f:
f.write(b"<html></html>")
req = urllib.request.Request("file://" + path)
try:
with urllib.request.urlopen(req, timeout=2) as r:
print("file_protocol:", r.url, r.status)
except Exception as e:
print("file_exception:", type(e).__name__, str(e))
srv = http.server.HTTPServer(("127.0.0.1", 0), RedirectHandler)
port = srv.server_address[1]
t = threading.Thread(target=srv.serve_forever, daemon=True)
t.start()
time.sleep(0.2)
try:
req = urllib.request.Request(f"http://127.0.0.1:{port}/redirect")
with urllib.request.urlopen(req, timeout=2) as r:
print("protocol:", r.url, r.status)
except Exception as e:
print("redirect_exception:", type(e).__name__, str(e), getattr(e, "reason", None))
finally:
srv.shutdown()
srv.server_close()
PYRepository: ChuckBuilds/ledmatrix-plugins
Length of output: 5954
Reject redirects to non-HTTPS destinations.
The scheme check only covers the initial https:// URL. urllib.request.urlopen follows redirects. If a redirect lands on http://, the probe still makes a non-HTTPS request. Use a redirect handler that calls is_safe_redirect_url(...) and rejects unsafe redirect targets before urlopen proceeds.
🧰 Tools
🪛 ast-grep (0.45.0)
[warning] 171-171: Request-controlled URL passed to urlopen; validate against an allowlist to prevent SSRF.
Context: urllib.request.urlopen(request, timeout=timeout)
Note: [CWE-918] Server-Side Request Forgery (SSRF).
(urlopen-unsanitized-data)
🪛 Ruff (0.16.1)
[error] 165-165: Audit URL open for permitted schemes. Allowing use of file: or custom schemes is often unexpected.
(S310)
[error] 172-172: Audit URL open for permitted schemes. Allowing use of file: or custom schemes is often unexpected.
(S310)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/check_espn_api.py` around lines 156 - 172, Update probe to use a
custom urllib redirect handler that validates each redirect target with
is_safe_redirect_url(...) and rejects any non-HTTPS destination before following
it. Preserve the existing initial URL scheme check, request headers, timeout,
and never-raises failure behavior while ensuring the handler is used by the
urlopen call.
Around 11:00 EDT on 2026-08-04, ESPN's
site.apibegan rejecting the User-Agent strings these plugins send, and every ESPN-backed scoreboard started returning403 Client Error: Forbidden. A device that had been running fine logged 287 ESPN errors in a day.The filter is the opposite of the usual one
Probed
site.apiacross agents, usingrequestsas the plugins do:LEDMatrix/1.0Accept)LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)requests/urllib/curldefaultsESPN rejects browser-style strings outright and bare custom tokens, while accepting honest client tokens or an agent that identifies the client and links to it. The reflex fix — "send a browser User-Agent" — is now the one change guaranteed to stay blocked.
Confirmed independently: ha-teamtracker#355 hit the same wall and switched from a browser agent to
curl/8.20.0(merged 2026-08-05).The change
Rather than guess at the blast radius, every User-Agent literal in the repo was collected and probed. Ten blocked strings, across 24 files in 14 plugins:
LEDMatrix/1.0family in thedata_sources.py/dynamic_team_resolver.pyfiles (16 files)LEDMatrix-F1/1.0,LEDMatrix Masters Plugin/2.0and/2.1,LEDMatrix Baseball Plugin/1.0,LEDMatrix/2.0,LEDMatrix-NewsPlugin/1.0 (RSS Reader)nfl-draft/manager.pyandmasters-tournament/download_assets.pyAll now send one agent carrying the project URL — a single string, so the next ESPN change is one grep rather than ten. Re-running the sweep afterwards reports zero blocked agents on any ESPN caller.
Callers that reach other services are deliberately untouched:
ledmatrix-flights,ledmatrix-stocksandstock-newssend a browser agent to hosts ESPN's change never involved.The probe was reporting healthy through the outage
scripts/check_espn_api.py(added earlier on this branch) printed "All 10 endpoints healthy under every User-Agent. ESPN is not the problem" while 7 of 10 endpoints were visibly refusing an agent. It only tested for the old shape — browser works, ours does not — so an inverted filter registered as fine, and the remedy it printed, "send a browser User-Agent from every ESPN caller", was precisely the change that would have kept everything broken.Fixed: the verdict is direction-agnostic, names which agents were accepted and which refused instead of assuming, and splits profiles into what the plugins actually ship versus controls kept only to characterise the filter. A split that leaves every shipped agent working is now a warning rather than a failure, so the non-zero exit still means "act now".
Verification
On a live 512x64 device, across a service restart:
mlb_live=True, MLB=4 live)Notes for review
1.10.3), hockey (1.7.3) and lacrosse (1.7.3) each skip a patch number so this cannot collide with the versions chore(sports): drop the dead second copy of the fallback scroll classes #252 already claims. Either merge order works.plugins.jsonwas regenerated by the pre-commit hook, not hand-edited.src/base_classes/data_sources.pyin ChuckBuilds/LEDMatrix, which had the same bare token. Without it those calls keep 403ing even with this merged — see fix(espn): send an identifying User-Agent so ESPN stops returning 403 LEDMatrix#436.🤖 Generated with Claude Code
https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
Summary by CodeRabbit
Bug Fixes
New Features
Chores