fix(logos): one hardened logo download path; shared, real HTTP headers (#612)

* fix(logos): harden the plugin logo download and share core HTTP headers

download_missing_logo / LogoDownloader.download_logo, the path the
scoreboard plugins use, read response.content with no size cap and wrote
straight to the final path, so a failed or corrupt download could be left
in place and cached as the logo. It now goes through fetch_logo: streamed
with a 10 MB cap, image/* only, decoded by Pillow, converted to RGBA once,
and moved into place atomically. A failure leaves no partial or temp file
and keeps any logo already on disk. LogoHelper._download_logo delegates to
the same code. Public signatures and return values are unchanged; saved
files are pixel-identical to before (RGBA, palette+tRNS, L+tRNS, LA, JPEG).

download_missing_logo reuses one downloader per thread instead of a new
Session per logo. Per thread rather than behind a lock: Session is not
documented thread-safe, and a lock would serialise every plugin's
downloads behind the slowest one.

Placeholders are written atomically, without the test_write.tmp probe.

The logo downloader and background data service now send the real
ChuckBuilds User-Agent from src.common.api_helper (USER_AGENT,
DEFAULT_HTTP_HEADERS) instead of a yourusername/contact@example.com
placeholder, and no longer hand-set Accept-Encoding: br (brotli is not
installed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(http): drop APIHelper's hand-set brotli encoding; LogoHelper sends the real UA

APIHelper advertised `br` though brotli isn't installed, so a server that
honoured it would send a body requests can't decode. LogoHelper sent a bare
`LEDMatrix-Common/1.0`, the kind of User-Agent ESPN has been rejecting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-23 12:58:11 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f813ea2117
commit 3f8edf5113
8 changed files with 622 additions and 153 deletions
+80
View File
@@ -0,0 +1,80 @@
"""One set of HTTP headers for core's ESPN/data requests.
The logo downloader and the background data service each carried their own
header dict with a placeholder User-Agent (``yourusername/LEDMatrix;
contact@example.com``) -- the kind of nonconforming token ESPN began 403ing
around 2026-08-04 -- and a hand-set ``Accept-Encoding: ... br`` although brotli
is not installed, so a ``br`` body could not have been decoded.
"""
from unittest.mock import MagicMock
import pytest
from src.common.api_helper import DEFAULT_HTTP_HEADERS, USER_AGENT, APIHelper
def _lower_keys(headers):
return {k.lower(): v for k, v in headers.items()}
class TestSharedHeaders:
def test_user_agent_names_the_project(self):
assert USER_AGENT == 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)'
assert DEFAULT_HTTP_HEADERS['User-Agent'] == USER_AGENT
def test_no_hand_set_accept_encoding(self):
assert 'accept-encoding' not in _lower_keys(DEFAULT_HTTP_HEADERS)
def test_is_read_only(self):
with pytest.raises(TypeError):
DEFAULT_HTTP_HEADERS['User-Agent'] = 'x' # type: ignore[index]
def test_api_helper_sends_the_same_user_agent(self):
assert APIHelper().session.headers['User-Agent'] == USER_AGENT
def test_api_helper_does_not_hand_set_brotli(self):
assert 'br' not in APIHelper().session.headers.get('Accept-Encoding', '')
def test_logo_helper_sends_the_same_user_agent(self):
from src.common.logo_helper import LogoHelper
assert LogoHelper(display_width=64, display_height=32).session.headers['User-Agent'] == USER_AGENT
class TestLogoDownloaderHeaders:
def test_uses_the_shared_headers(self):
from src.logo_downloader import LogoDownloader
headers = LogoDownloader().headers
assert headers['User-Agent'] == USER_AGENT
assert 'accept-encoding' not in _lower_keys(headers)
assert 'yourusername' not in str(headers)
def test_instance_headers_are_a_private_copy(self):
from src.logo_downloader import LogoDownloader
downloader = LogoDownloader()
downloader.headers['X-Test'] = '1'
assert 'X-Test' not in DEFAULT_HTTP_HEADERS
assert 'X-Test' not in LogoDownloader().headers
def test_logo_request_sends_the_user_agent_and_asks_for_an_image(self, tmp_path):
from src.logo_downloader import LogoDownloader
downloader = LogoDownloader()
downloader.session.get = MagicMock(side_effect=RuntimeError("stop"))
downloader.download_logo("http://x/a.png", tmp_path / "A.png", "A")
sent = _lower_keys(downloader.session.get.call_args.kwargs['headers'])
assert sent['user-agent'] == USER_AGENT
assert sent['accept'].startswith('image/')
assert 'accept-encoding' not in sent
class TestBackgroundDataServiceHeaders:
def test_uses_the_shared_headers(self):
from src.background_data_service import BackgroundDataService
service = BackgroundDataService(MagicMock(), max_workers=1)
try:
headers = service.default_headers
assert headers['User-Agent'] == USER_AGENT
assert 'accept-encoding' not in _lower_keys(headers)
assert 'yourusername' not in str(headers)
finally:
service.shutdown(wait=False)