Conversation
Converts the UFC/MMA support from PR #137 (by Alex Resnick / legoguy1000) into a standalone plugin following the BasePlugin architecture. Includes fighter headshots, records, odds, vegas scroll, dynamic duration, element positioning, and comprehensive config schema. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
📝 WalkthroughWalkthroughThis pull request introduces a complete UFC Scoreboard plugin for LEDMatrix that displays live, recent, and upcoming UFC fights on LED displays. The plugin includes data fetching from ESPN, fight card rendering with fighter headshots, configurable layouts, odds management, and scrolling display support. Changes
Sequence Diagram(s)sequenceDiagram
participant App as LEDMatrix App
participant Plugin as UFCScoreboardPlugin
participant Manager as UFC Manager<br/>(Live/Recent/Upcoming)
participant DataSource as DataSource<br/>(ESPN)
participant OddsManager as BaseOddsManager
participant Renderer as FightRenderer
participant Display as Display Manager
App->>Plugin: update()
Plugin->>Manager: update()
Manager->>DataSource: fetch_live_games() or fetch_schedule()
DataSource-->>Manager: game events list
Manager->>OddsManager: get_odds(event_id)
OddsManager-->>Manager: odds data (cached)
Manager->>Manager: _extract_game_details()
Manager->>Renderer: render_fight_card(fight)
Renderer-->>Manager: PIL Image
Plugin->>Display: display(image)
Display-->>App: rendered on LED
sequenceDiagram
participant Plugin as UFCScoreboardPlugin
participant ScrollMgr as ScrollDisplayManager
participant Renderer as FightRenderer
participant Helper as ScrollHelper
participant Display as Display Manager
Plugin->>ScrollMgr: prepare_and_display(fights)
ScrollMgr->>Renderer: render_fight_card() for each fight
Renderer-->>ScrollMgr: PIL Images
ScrollMgr->>ScrollMgr: load_separator_icons()
ScrollMgr->>Helper: create_scrolling_image(cards)
Helper-->>ScrollMgr: wide composite image
loop Per Frame
Plugin->>ScrollMgr: display_scroll_frame()
ScrollMgr->>Display: display(frame region)
Display-->>Plugin: rendered
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
⚔️ Resolve merge conflicts (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 |
|
Wrong repo - this plugin belongs in the ledmatrix-plugins monorepo |
There was a problem hiding this comment.
Actionable comments posted: 14
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
🤖 Fix all issues with AI agents
In `@plugin-repos/ufc-scoreboard/base_odds_manager.py`:
- Around line 166-173: The except handlers currently log errors but then fall
through to returning self.cache_manager.get(cache_key), which can return a
cached sentinel like {"no_odds": True}; modify the error path so that on
requests.exceptions.RequestException and json.JSONDecodeError you return None
immediately after logging (i.e., add return None inside those except blocks or
otherwise ensure the method returns None on error instead of calling
self.cache_manager.get(cache_key)); update references to the cache access
(self.cache_manager.get(cache_key)) only for the success path.
- Around line 276-284: The clear_cache implementation uses a different key
pattern than get_odds so it never deletes specific entries; update clear_cache
to accept comp_id (e.g., def clear_cache(self, sport: str = None, league: str =
None, event_id: str = None, comp_id: str = None)), and when sport, league,
event_id (and comp_id when provided) are present, build the same cache_key
format used in get_odds (odds_espn_{sport}_{league}_{event_id}_{comp_id}) and
call self.cache_manager.delete(cache_key); if comp_id is missing but the intent
is to clear all comps for an event, iterate or use a prefix-based deletion if
supported by cache_manager, otherwise fall back to clear() and log accordingly
(refer to get_odds, clear_cache, cache_key, cache_manager.delete).
- Around line 107-118: The cached "no_odds" marker is only logged but not acted
on, so when cache_manager.get(cache_key) returns a dict with "no_odds" you
should immediately stop and return None (or a sentinel) instead of falling
through to the API fetch; update the branch that checks isinstance(cached_data,
dict) and cached_data.get("no_odds") inside the method that builds cache_key to
return early. Also remove the unused local variable interval (the assignment
from update_interval_seconds or self.update_interval) — either delete that line
or use update_interval_seconds directly where needed to eliminate the F841
unused-variable warning.
In `@plugin-repos/ufc-scoreboard/config_schema.json`:
- Around line 59-97: The display toggle keys in the JSON schema ("show_live",
"show_recent", "show_upcoming") don't match the keys read in ufc_managers.py, so
self.recent_enabled, self.upcoming_enabled and self.live_enabled will always be
False; update the code that reads display_modes (the lines setting
self.recent_enabled, self.upcoming_enabled, self.live_enabled in the
ufc_managers.py initializer) to use the schema keys "show_recent",
"show_upcoming", and "show_live" (e.g., display_modes.get("show_recent", False)
etc.) so user-configured toggles take effect.
In `@plugin-repos/ufc-scoreboard/data_sources.py`:
- Around line 83-88: The filtering for live_events uses
event.get('competitions', [{}])[0] which still raises IndexError when
competitions is an empty list; update the guard to handle both missing and empty
lists (e.g., use event.get('competitions') or [{}] before indexing) or
explicitly check truthiness/length of competitions; apply the change in the
live_events comprehension that references events and
event.get('competitions'...) so it safely obtains the first competition before
accessing .get('status')/.get('type')/.get('state').
In `@plugin-repos/ufc-scoreboard/fight_renderer.py`:
- Around line 150-168: The code opens headshot files with
Image.open(headshot_path) without closing them and uses Image.Resampling.LANCZOS
which breaks on older Pillow; fix by opening images with a context manager (use
"with Image.open(headshot_path) as img:"), perform convert/thumbnail inside the
with-block, make a detached copy (e.g. copy = img.copy() or convert result) and
store that copy in self._headshot_cache[fighter_id] so the original file handle
is released, and replace Image.Resampling.LANCZOS with a backward-compatible
lookup (use Image.Resampling.LANCZOS if present, otherwise fall back to
Image.LANCZOS) when calling thumbnail. Ensure all references to headshot_path,
Image.open, thumbnail, and self._headshot_cache are updated accordingly.
In `@plugin-repos/ufc-scoreboard/headshot_downloader.py`:
- Around line 21-56: HeadshotDownloader builds a requests.Session with retries
and headers but download_missing_headshot bypasses it by calling requests.get
directly; fix by converting download_missing_headshot into an instance method on
HeadshotDownloader (or accept a session parameter) and replace the raw
requests.get call with self.session.get(...) using
HeadshotDownloader.get_headshot_url(fighter_id), include self.headers and
self.request_timeout, and propagate/handle HTTP errors the same way so retries
and headers are respected; update any call sites to instantiate
HeadshotDownloader and call the new method (or pass the session) accordingly.
In `@plugin-repos/ufc-scoreboard/manager.py`:
- Around line 1136-1155: The loop is mutating fight dicts returned by
_get_games_from_manager which can corrupt manager state; fix by shallow-copying
each fight before any mutation (e.g., create fight_copy = dict(fight) or
similar) inside the loop over manager_fights, then set fight_copy["league"] =
"ufc" and ensure fight_copy["status"] is a dict and has "state" set via the
state_map; append/extend using the copies (not the original manager_fights) so
_get_games_from_manager and managers remain unmodified.
In `@plugin-repos/ufc-scoreboard/mma.py`:
- Around line 903-917: The _test_mode_update method incorrectly uses
self.current_game["period"] and assumes self.current_game["clock"] exists;
change references from "period" to "round" (use self.current_game.get("round",
1)) and determine max rounds from a safe source (e.g.,
self.current_game.get("total_rounds", 3) or a class constant) instead of
hardcoding 3, and guard access to the clock by using
self.current_game.get("clock") (return early if missing or malformed) before
splitting; update the rollover logic to increment round up to max_rounds then
reset/stop as appropriate (use self.current_game["round"] = min(current_round+1,
max_rounds) or reset to 1 based on desired behavior) and ensure minutes/seconds
bounds are handled safely.
- Around line 269-405: The _draw_scorebug_layout implementations duplicate
headshot loading, error handling, record/odds drawing and compositing across
MMARecent, MMAUpcoming, and MMALive; extract common work into MMA base-class
helpers (e.g., add methods _load_pair_headshots(game) returning (fighter1_image,
fighter2_image), _handle_headshot_error(game) to log and display the image
error, _draw_records(draw, game, record_font) to encapsulate the record
rendering logic, and _composite_and_display(main_img, overlay) to
alpha_composite/convert/paste/update); update each subclass's
_draw_scorebug_layout to call these helpers and keep only fight-specific layout
code, and cache the record font on the instance (load once in MMA.__init__ or as
a lazy property self.record_font) instead of calling ImageFont.truetype() on
every render to avoid repeated I/O on Raspberry Pi.
- Line 121: The return annotation of _extract_game_details uses the Python 3.10+
union syntax `Dict | None`, which will fail on Python 3.9; change the signature
of _extract_game_details to use typing.Optional instead (e.g., ->
Optional[Dict]) and ensure Optional and Dict are imported from typing (the
codebase already imports Optional, so just replace the `| None` union with
Optional[Dict] in the _extract_game_details function signature).
In `@plugin-repos/ufc-scoreboard/scroll_display.py`:
- Around line 365-371: The reset method in scroll_display.py calls a
non-existent ScrollHelper.reset(), which will raise AttributeError; change the
call in the reset method to use the actual ScrollHelper API by invoking
self.scroll_helper.reset_scroll() instead (update the reset method where
self._is_scrolling, self._current_fights, and self._frame_count are set so it
calls self.scroll_helper.reset_scroll()).
- Around line 160-163: Several places call Image.Resampling.LANCZOS (e.g., the
ufc_icon.resize call) which raises AttributeError on Pillow 8.x; replace usages
with Image.LANCZOS (or use a small compatibility helper like resample =
getattr(Image, "Resampling", Image); resample.LANCZOS) and update all
occurrences (e.g., in the ufc_icon.resize in scroll_display.py, the resize in
mma.py and fight_renderer.py, and similar calls in sports.py, logo_helper.py,
plugin_adapter.py, and image_utils.py) so the code works with Pillow 8.0.0 and
newer.
In `@plugin-repos/ufc-scoreboard/ufc_managers.py`:
- Around line 119-127: In fetch_callback, calling len(result.data.get('events'))
can raise TypeError if result.data is None or 'events' is missing; instead,
safely obtain events via something like events = (result.data or
{}).get('events') or [] and log len(events). Update the success branch (where
self.logger.info is called) to compute events this way before formatting the
message so it never calls len(None) and still reports zero when events are
absent.
🟡 Minor comments (8)
plugin-repos/ufc-scoreboard/config_schema.json-104-120 (1)
104-120:⚠️ Potential issue | 🟡 MinorInconsistent types and missing constraints across per-mode duration fields.
live_game_duration(Line 105) uses"type": "integer"withminimum/maximumconstraints, whilerecent_game_duration(Line 112) andupcoming_game_duration(Line 117) use"type": "number"and lackminimum/maximumbounds. This inconsistency could allow nonsensical values (e.g., 0 or negative durations) for recent/upcoming modes.🔧 Suggested fix: unify types and add bounds
"recent_game_duration": { - "type": "number", + "type": "integer", "default": 15, + "minimum": 5, + "maximum": 120, "description": "Duration in seconds to show each recent fight before rotating. Falls back to game_display_duration if not set." }, "upcoming_game_duration": { - "type": "number", + "type": "integer", "default": 15, + "minimum": 5, + "maximum": 120, "description": "Duration in seconds to show each upcoming fight before rotating. Falls back to game_display_duration if not set." },plugin-repos/ufc-scoreboard/data_sources.py-68-70 (1)
68-70:⚠️ Potential issue | 🟡 Minor
datetime.now()without timezone — inconsistent with UTC usage inufc_managers.py.Line 69 uses
datetime.now()(local time), whileufc_managers.pyLine 68 usesdatetime.now(timezone.utc). For API date formatting this may work if the Pi's local clock matches the intended date, but it introduces timezone ambiguity. Consider using UTC consistently. As per coding guidelines: "Handle different deployment contexts with environment awareness in code."🔧 Proposed fix
- now = datetime.now() + now = datetime.now(timezone.utc)Also add
timezoneto the import on Line 12:-from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezoneplugin-repos/ufc-scoreboard/ufc_managers.py-22-27 (1)
22-27:⚠️ Potential issue | 🟡 MinorRemove unused class variables from
BaseUFCManager.Lines 26-27 (
_shared_data,_last_shared_update) are never referenced anywhere in the codebase and should be removed as dead code. Also remove_no_data_warning_logged(line 23) and_warning_cooldown(line 25), which are similarly unused. The_last_warning_timevariable (line 24) is used by the parentSportsCoreclass for warning cooldown tracking and should be kept as an instance variable, not a class variable—the parent class already handles this with defensivehasattr/setattrchecks.plugin-repos/ufc-scoreboard/manager.py-706-706 (1)
706-706:⚠️ Potential issue | 🟡 Minorf-string without any placeholders — remove the
fprefix.🐛 Proposed fix
- self.logger.info(f"has_live_content() returning False") + self.logger.info("has_live_content() returning False")plugin-repos/ufc-scoreboard/mma.py-56-58 (1)
56-58:⚠️ Potential issue | 🟡 Minor120-second timeout for headshot download is excessive for a Raspberry Pi.
A 120s timeout can block the main display loop for a long time on a slow connection. The separate
headshot_downloader.pymodule uses 30s, which is more appropriate.🛡️ Proposed fix
- response = self.session.get(image_url, headers=self.headers, timeout=120) + response = self.session.get(image_url, headers=self.headers, timeout=30)plugin-repos/ufc-scoreboard/fight_renderer.py-116-120 (1)
116-120:⚠️ Potential issue | 🟡 Minor
int()cast on config values can raiseValueErrorif the config contains a non-numeric string.If a user puts
"auto"or an empty string in a layout offset config field, this will crash. The relevant snippet (lines 271-305 of this file) shows a more robust version that handles strings and exceptions — but it's not what's used here.🛡️ Proposed fix — add safe conversion
def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: """Get layout offset for an element from config.""" - layout_config = self.config.get("customization", {}).get("layout", {}) - element_config = layout_config.get(element, {}) - return int(element_config.get(axis, default)) + try: + layout_config = self.config.get("customization", {}).get("layout", {}) + element_config = layout_config.get(element, {}) + return int(element_config.get(axis, default)) + except (ValueError, TypeError): + return defaultplugin-repos/ufc-scoreboard/manager.py-578-620 (1)
578-620:⚠️ Potential issue | 🟡 MinorUnused variable and silently swallowed exception.
- Line 580:
manager_class_nameis assigned but never used.- Lines 619-620: The bare
except: passsilently swallows errors from_record_dynamic_progress, making debugging difficult. At minimum, log at debug level.🐛 Proposed fix
- manager_class_name = manager.__class__.__name__ current_game = getattr(manager, "current_game", None)try: self._record_dynamic_progress( manager, actual_mode=actual_mode, display_mode=display_mode ) - except Exception: - pass + except Exception as e: + self.logger.debug(f"Dynamic progress tracking failed: {e}")plugin-repos/ufc-scoreboard/mma.py-786-804 (1)
786-804:⚠️ Potential issue | 🟡 MinorDuplicate condition check and incorrect
not ... insyntax.Line 788 and 790 both check
game and game["is_upcoming"]. The second check is redundant. Additionally, line 801-802 usesnot (...) inwhich should benot inper PEP style (E713).🐛 Proposed fix — merge conditions and fix `not in`
for event in flattened_events: game = self._extract_game_details(event) - if game and game["is_upcoming"]: - all_upcoming_games += 1 - if game and game["is_upcoming"]: + if not game or not game["is_upcoming"]: + continue + all_upcoming_games += 1 + if self.show_favorite_teams_only and ( + len(self.favorite_fighters) > 0 + or len(self.favorite_weight_class) > 0 + ): if ( - not ( - game["fighter1_name"].lower() in self.favorite_fighters - or game["fighter2_name"].lower() - in self.favorite_fighters - ) - and not game["fight_class"].lower() - in self.favorite_weight_class + game["fighter1_name"].lower() not in self.favorite_fighters + and game["fighter2_name"].lower() not in self.favorite_fighters + and game["fight_class"].lower() not in self.favorite_weight_class ): continue + else: + favorite_games_found += 1 + if self.show_odds: + self._fetch_odds(game) + processed_games.append(game)
🧹 Nitpick comments (15)
plugin-repos/ufc-scoreboard/generate_placeholder_icon.py (2)
32-38: Font loading: catchingOSErrorwould be more precise, but the fallback chain is acceptable here.The static analysis flags
Exceptioncatches on Lines 34 and 37. For font loading, PIL raisesOSError(orIOError) when a font file is missing. However, since this is a best-effort fallback chain in a utility script and the behavior is correct, this is a minor nit.♻️ Optional: narrow the exception type
try: font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) - except Exception: + except OSError: try: font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 14) - except Exception: + except OSError: font = ImageFont.load_default()
56-57: Output path assumes execution from repository root.The
__main__block writes to a relative pathassets/sports/ufc_logos/UFC.png. This will fail if the script is run from a different working directory. Consider either documenting this requirement in the docstring or usingPath(__file__).parentto resolve the path relative to the script.plugin-repos/ufc-scoreboard/data_sources.py (1)
13-13: Unused import:time.The
timemodule is imported but never used in this file.♻️ Remove unused import
-import timeplugin-repos/ufc-scoreboard/headshot_downloader.py (1)
83-90: Write-test file for permission checking could race in concurrent contexts.Creating
.write_test(Lines 84-87) to probe write permissions is a TOCTOU pattern — the permission could change between the test and the actual write, and concurrent calls could conflict on the same file. On a single-threaded Raspberry Pi plugin this is low risk, but consider usingos.access()as a lighter check or just attempting the write directly and handlingPermissionError.plugin-repos/ufc-scoreboard/base_odds_manager.py (2)
140-148: Rawrequests.get()without retry strategy; verbose JSON dump at debug level.Line 140 uses
requests.get()directly with no session or retry setup, unlikeDataSourceandHeadshotDownloaderwhich both configure retry adapters. A transient 500 from ESPN will fail immediately.Line 147 dumps the entire raw API response as JSON at DEBUG level — this can be very large for events with many fights and will slow logging on a Raspberry Pi.
♻️ Add a session with retries and trim the log
Add session setup in
__init__:self.session = requests.Session() retry_strategy = Retry(total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504]) adapter = HTTPAdapter(max_retries=retry_strategy) self.session.mount("https://", adapter) self.session.mount("http://", adapter)Then use
self.session.get(...)on Line 140, and truncate the debug log:- self.logger.debug( - f"Received raw odds data from ESPN: {json.dumps(raw_data, indent=2)}" - ) + self.logger.debug( + f"Received odds data from ESPN, keys: {list(raw_data.keys())}" + )As per coding guidelines: "Optimize code for Raspberry Pi's limited RAM and CPU capabilities" and "Implement comprehensive logging for remote debugging on Raspberry Pi."
237-273:zip()withoutstrict=Truewill silently truncate ifevent_idsandcomp_idsdiffer in length.If the caller passes mismatched lists, the shorter list silently wins, dropping odds fetches for trailing events.
♻️ Add length validation
if comp_ids is None: comp_ids = event_ids + + if len(event_ids) != len(comp_ids): + self.logger.error( + f"event_ids ({len(event_ids)}) and comp_ids ({len(comp_ids)}) length mismatch" + ) + return results - for event_id, comp_id in zip(event_ids, comp_ids): + for event_id, comp_id in zip(event_ids, comp_ids, strict=True):plugin-repos/ufc-scoreboard/ufc_managers.py (1)
163-170:isinstance(self, UFCLiveManager)check is fragile — prefer method override.Using
isinstancein a base class to dispatch behavior based on the subclass type (Line 165) defeats the purpose of inheritance. If a new subclass is added, this method must be updated. Override_fetch_datain each subclass instead.♻️ Proposed refactor using method override
In
BaseUFCManager:def _fetch_data(self) -> Optional[Dict]: - """Fetch data using shared data mechanism or direct fetch for live.""" - if isinstance(self, UFCLiveManager): - # Live games should fetch only current games, not entire season - return self._fetch_todays_games() - else: - # Recent and Upcoming managers should use cached season data - return self._fetch_ufc_api_data(use_cache=True) + """Fetch season schedule data (default for recent/upcoming).""" + return self._fetch_ufc_api_data(use_cache=True)In
UFCLiveManager, add the override:def _fetch_data(self) -> Optional[Dict]: """Fetch only today's games for live display.""" return self._fetch_todays_games()plugin-repos/ufc-scoreboard/scroll_display.py (3)
91-131: Redundantset_scroll_speedcall — first value is immediately overwritten.
set_scroll_speedis called on line 102 with the rawscroll_speed(e.g. 50.0), then overwritten on line 124 with the clampedpixels_per_frame. The first call has no effect.♻️ Remove the redundant first call
- self.scroll_helper.set_scroll_speed(scroll_speed) self.scroll_helper.set_scroll_delay(scroll_delay)
198-204: Use explicitOptionaltype hint forrankings_cache.PEP 484 prohibits implicit
Optional. UsingNoneas a default withoutOptionalannotation can mislead type checkers.♻️ Proposed fix
def prepare_and_display( self, fights: List[Dict], fight_type: str, leagues: List[str], - rankings_cache: Dict[str, int] = None, + rankings_cache: Optional[Dict[str, int]] = None, ) -> bool:
233-240: NewFightRendereris instantiated on everyprepare_and_displaycall.Each call creates a new renderer (including font loading). Consider caching it as an instance attribute, since the display dimensions and config don't change between calls. This is particularly relevant for Raspberry Pi's limited CPU. As per coding guidelines, "Optimize code for Raspberry Pi's limited RAM and CPU capabilities."
♻️ Cache the renderer
# In __init__: + self._fight_renderer = FightRenderer( + self.display_width, + self.display_height, + self.config, + headshot_cache=self._headshot_cache, + custom_logger=self.logger, + ) + # In prepare_and_display: - renderer = FightRenderer( - self.display_width, - self.display_height, - self.config, - headshot_cache=self._headshot_cache, - custom_logger=self.logger, - ) + renderer = self._fight_rendererplugin-repos/ufc-scoreboard/mma.py (2)
124-128: Direct key access on API data can raiseKeyError/IndexErrorif ESPN response is malformed.
game_event["competitions"][0],competition["status"], andcompetition["competitors"]use direct indexing. While wrapped in a broadexcept Exception, a fail-fast validation with a clear message would be more debuggable. As per coding guidelines, "Validate inputs and handle errors early."♻️ Add early validation
try: - competition = game_event["competitions"][0] - status = competition["status"] - competitors = competition["competitors"] + competitions = game_event.get("competitions") + if not competitions: + self.logger.warning(f"No competitions in event {game_event.get('id')}") + return None + competition = competitions[0] + status = competition.get("status") + competitors = competition.get("competitors") + if not status or not competitors: + self.logger.warning(f"Missing status or competitors in competition {competition.get('id')}") + return None
80-97: Image is saved inside thewithblock but file handle behavior is safe here.
img.save(image_path, "PNG")on line 84 works correctly within the context manager. However, immediately after thewithblock closes, lines 105-107 reopen the same file. The double open/close is unnecessary — you could keep the converted image in memory instead of re-reading from disk.♻️ Avoid redundant re-read from disk
try: with Image.open(image_path) as img: if img.mode != "RGBA": img = img.convert("RGBA") - img.save(image_path, "PNG") - - self.logger.info(...) + img.save(image_path, "PNG") + logo = img.copy() + self.logger.info(...) except Exception as e: ... return None - if not image_path.exists(): - ... - return None - - logo = Image.open(image_path) - if logo.mode != "RGBA": - logo = logo.convert("RGBA") - max_width = int(self.display_width * 1.5) max_height = int(self.display_height * 1.5) logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)plugin-repos/ufc-scoreboard/fight_renderer.py (1)
139-144: Use explicitOptionaltype hints for parameters defaulting toNone.
headshot_url: str = Noneshould beheadshot_url: Optional[str] = None. Same applies tofight_typeanddisplay_optionsinrender_fight_card(lines 177-178).♻️ Proposed fix
def _load_headshot( self, fighter_id: str, fighter_name: str, headshot_path: Path, - headshot_url: str = None, + headshot_url: Optional[str] = None, ) -> Optional[Image.Image]:def render_fight_card( self, fight: Dict[str, Any], - fight_type: str = None, - display_options: Dict[str, Any] = None, + fight_type: Optional[str] = None, + display_options: Optional[Dict[str, Any]] = None, ) -> Optional[Image.Image]:plugin-repos/ufc-scoreboard/manager.py (2)
59-68: Instance attributes reassigned aftersuper().__init__()may override base class setup.Lines 64-68 unconditionally reassign
plugin_id,config,display_manager,cache_manager, andplugin_manager— attributes thatBasePlugin.__init__()likely already sets. This is harmless whenBasePluginis available (same values), but the pattern is confusing. Consider guarding the reassignment withif not BasePlugin:or removing the duplication.♻️ Proposed fix — only assign when BasePlugin is not available
if BasePlugin: super().__init__( plugin_id, config, display_manager, cache_manager, plugin_manager ) - - self.plugin_id = plugin_id - self.config = config - self.display_manager = display_manager - self.cache_manager = cache_manager - self.plugin_manager = plugin_manager + else: + self.plugin_id = plugin_id + self.config = config + self.display_manager = display_manager + self.cache_manager = cache_manager + self.plugin_manager = plugin_manager
1202-1209:cleanup()is essentially a no-op — scroll manager and headshot caches are not cleaned up.The method logs completion but doesn't release resources. On Raspberry Pi, failing to clear cached images can leave significant memory allocated. As per coding guidelines, "Clean up resources regularly to manage memory effectively."
♻️ Proposed fix — clean up caches and scroll state
def cleanup(self) -> None: """Clean up resources.""" try: - if hasattr(self, "background_service") and self.background_service: - pass + if self._scroll_manager: + self._scroll_manager.reset() + # Clear manager references + for attr in ("ufc_live", "ufc_recent", "ufc_upcoming"): + manager = getattr(self, attr, None) + if manager and hasattr(manager, "_logo_cache"): + manager._logo_cache.clear() self.logger.info("UFC scoreboard plugin cleanup completed") except Exception as e: self.logger.error(f"Error during cleanup: {e}")
| interval = update_interval_seconds or self.update_interval | ||
| cache_key = f"odds_espn_{sport}_{league}_{event_id}_{comp_id}" | ||
|
|
||
| # Check cache first | ||
| cached_data = self.cache_manager.get(cache_key) | ||
|
|
||
| if cached_data: | ||
| if isinstance(cached_data, dict) and cached_data.get("no_odds"): | ||
| self.logger.debug(f"Cached no-odds marker for {cache_key}, skipping") | ||
| else: | ||
| self.logger.info(f"Using cached odds from ESPN for {cache_key}") | ||
| return cached_data |
There was a problem hiding this comment.
Cached "no_odds" marker doesn't prevent re-fetching — falls through to the API call.
When the cache contains a {"no_odds": True} marker (Line 114), the code logs a debug message but doesn't return None. Execution falls through to the fresh-fetch block on Line 120, defeating the purpose of caching no-odds to avoid repeated API calls.
Also, interval on Line 107 is assigned but never used (confirmed by static analysis F841).
🐛 Proposed fix
# Check cache first
cached_data = self.cache_manager.get(cache_key)
if cached_data:
if isinstance(cached_data, dict) and cached_data.get("no_odds"):
self.logger.debug(f"Cached no-odds marker for {cache_key}, skipping")
+ return None
else:
self.logger.info(f"Using cached odds from ESPN for {cache_key}")
return cached_dataAnd remove the unused variable:
- # Use provided interval or default
- interval = update_interval_seconds or self.update_interval📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| interval = update_interval_seconds or self.update_interval | |
| cache_key = f"odds_espn_{sport}_{league}_{event_id}_{comp_id}" | |
| # Check cache first | |
| cached_data = self.cache_manager.get(cache_key) | |
| if cached_data: | |
| if isinstance(cached_data, dict) and cached_data.get("no_odds"): | |
| self.logger.debug(f"Cached no-odds marker for {cache_key}, skipping") | |
| else: | |
| self.logger.info(f"Using cached odds from ESPN for {cache_key}") | |
| return cached_data | |
| interval = update_interval_seconds or self.update_interval | |
| cache_key = f"odds_espn_{sport}_{league}_{event_id}_{comp_id}" | |
| # Check cache first | |
| cached_data = self.cache_manager.get(cache_key) | |
| if cached_data: | |
| if isinstance(cached_data, dict) and cached_data.get("no_odds"): | |
| self.logger.debug(f"Cached no-odds marker for {cache_key}, skipping") | |
| return None | |
| else: | |
| self.logger.info(f"Using cached odds from ESPN for {cache_key}") | |
| return cached_data |
🧰 Tools
🪛 Ruff (0.15.0)
[error] 107-107: Local variable interval is assigned to but never used
Remove assignment to unused variable interval
(F841)
🤖 Prompt for AI Agents
In `@plugin-repos/ufc-scoreboard/base_odds_manager.py` around lines 107 - 118, The
cached "no_odds" marker is only logged but not acted on, so when
cache_manager.get(cache_key) returns a dict with "no_odds" you should
immediately stop and return None (or a sentinel) instead of falling through to
the API fetch; update the branch that checks isinstance(cached_data, dict) and
cached_data.get("no_odds") inside the method that builds cache_key to return
early. Also remove the unused local variable interval (the assignment from
update_interval_seconds or self.update_interval) — either delete that line or
use update_interval_seconds directly where needed to eliminate the F841
unused-variable warning.
| except requests.exceptions.RequestException as e: | ||
| self.logger.error(f"Error fetching odds from ESPN API for {cache_key}: {e}") | ||
| except json.JSONDecodeError: | ||
| self.logger.error( | ||
| f"Error decoding JSON response from ESPN API for {cache_key}." | ||
| ) | ||
|
|
||
| return self.cache_manager.get(cache_key) |
There was a problem hiding this comment.
Error fallback may return the {"no_odds": True} marker to the caller.
On request/parse failure (Lines 166-171), the code falls through to Line 173 which returns self.cache_manager.get(cache_key). If a previous successful call cached {"no_odds": True}, this will return that marker dict to the caller, which likely doesn't expect it and may treat it as valid odds data.
🐛 Proposed fix: return None on error
except json.JSONDecodeError:
self.logger.error(
f"Error decoding JSON response from ESPN API for {cache_key}."
)
- return self.cache_manager.get(cache_key)
+ return None📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| except requests.exceptions.RequestException as e: | |
| self.logger.error(f"Error fetching odds from ESPN API for {cache_key}: {e}") | |
| except json.JSONDecodeError: | |
| self.logger.error( | |
| f"Error decoding JSON response from ESPN API for {cache_key}." | |
| ) | |
| return self.cache_manager.get(cache_key) | |
| except requests.exceptions.RequestException as e: | |
| self.logger.error(f"Error fetching odds from ESPN API for {cache_key}: {e}") | |
| except json.JSONDecodeError: | |
| self.logger.error( | |
| f"Error decoding JSON response from ESPN API for {cache_key}." | |
| ) | |
| return None |
🧰 Tools
🪛 Ruff (0.15.0)
[warning] 167-167: Use logging.exception instead of logging.error
Replace with exception
(TRY400)
[warning] 169-171: Use logging.exception instead of logging.error
Replace with exception
(TRY400)
🤖 Prompt for AI Agents
In `@plugin-repos/ufc-scoreboard/base_odds_manager.py` around lines 166 - 173, The
except handlers currently log errors but then fall through to returning
self.cache_manager.get(cache_key), which can return a cached sentinel like
{"no_odds": True}; modify the error path so that on
requests.exceptions.RequestException and json.JSONDecodeError you return None
immediately after logging (i.e., add return None inside those except blocks or
otherwise ensure the method returns None on error instead of calling
self.cache_manager.get(cache_key)); update references to the cache access
(self.cache_manager.get(cache_key)) only for the success path.
| def clear_cache(self, sport: str = None, league: str = None, event_id: str = None): | ||
| """Clear odds cache for specific criteria.""" | ||
| if sport and league and event_id: | ||
| cache_key = f"odds_espn_{sport}_{league}_{event_id}" | ||
| self.cache_manager.delete(cache_key) | ||
| self.logger.info(f"Cleared cache for {cache_key}") | ||
| else: | ||
| self.cache_manager.clear() | ||
| self.logger.info("Cleared all cache") |
There was a problem hiding this comment.
clear_cache uses a different cache key pattern than get_odds, so it can never clear specific entries.
get_odds (Line 108) uses cache key odds_espn_{sport}_{league}_{event_id}_{comp_id}, but clear_cache (Line 279) constructs odds_espn_{sport}_{league}_{event_id} — missing the _{comp_id} suffix. The targeted delete will never match an existing cache entry.
🐛 Proposed fix: add comp_id parameter
- def clear_cache(self, sport: str = None, league: str = None, event_id: str = None):
+ def clear_cache(self, sport: str = None, league: str = None, event_id: str = None, comp_id: str = None):
"""Clear odds cache for specific criteria."""
- if sport and league and event_id:
- cache_key = f"odds_espn_{sport}_{league}_{event_id}"
+ if sport and league and event_id and comp_id:
+ cache_key = f"odds_espn_{sport}_{league}_{event_id}_{comp_id}"
self.cache_manager.delete(cache_key)
self.logger.info(f"Cleared cache for {cache_key}")
+ elif sport and league and event_id:
+ # Clear all comp_ids for this event (would need prefix-based delete)
+ cache_key = f"odds_espn_{sport}_{league}_{event_id}"
+ self.cache_manager.delete(cache_key)
+ self.logger.info(f"Cleared cache for {cache_key}")
else:
self.cache_manager.clear()
self.logger.info("Cleared all cache")🧰 Tools
🪛 Ruff (0.15.0)
[warning] 276-276: PEP 484 prohibits implicit Optional
Convert to T | None
(RUF013)
[warning] 276-276: PEP 484 prohibits implicit Optional
Convert to T | None
(RUF013)
[warning] 276-276: PEP 484 prohibits implicit Optional
Convert to T | None
(RUF013)
🤖 Prompt for AI Agents
In `@plugin-repos/ufc-scoreboard/base_odds_manager.py` around lines 276 - 284, The
clear_cache implementation uses a different key pattern than get_odds so it
never deletes specific entries; update clear_cache to accept comp_id (e.g., def
clear_cache(self, sport: str = None, league: str = None, event_id: str = None,
comp_id: str = None)), and when sport, league, event_id (and comp_id when
provided) are present, build the same cache_key format used in get_odds
(odds_espn_{sport}_{league}_{event_id}_{comp_id}) and call
self.cache_manager.delete(cache_key); if comp_id is missing but the intent is to
clear all comps for an event, iterate or use a prefix-based deletion if
supported by cache_manager, otherwise fall back to clear() and log accordingly
(refer to get_odds, clear_cache, cache_key, cache_manager.delete).
| "display_modes": { | ||
| "type": "object", | ||
| "title": "Display Modes", | ||
| "description": "Control which fight types to show and how they display", | ||
| "properties": { | ||
| "show_live": { | ||
| "type": "boolean", | ||
| "default": true, | ||
| "description": "Show live UFC fights" | ||
| }, | ||
| "show_recent": { | ||
| "type": "boolean", | ||
| "default": true, | ||
| "description": "Show recently completed UFC fights" | ||
| }, | ||
| "show_upcoming": { | ||
| "type": "boolean", | ||
| "default": true, | ||
| "description": "Show upcoming UFC fights" | ||
| }, | ||
| "live_display_mode": { | ||
| "type": "string", | ||
| "enum": ["switch", "scroll"], | ||
| "default": "switch", | ||
| "description": "Display mode for live fights: 'switch' rotates fights one at a time, 'scroll' scrolls all fights horizontally" | ||
| }, | ||
| "recent_display_mode": { | ||
| "type": "string", | ||
| "enum": ["switch", "scroll"], | ||
| "default": "switch", | ||
| "description": "Display mode for recent fights" | ||
| }, | ||
| "upcoming_display_mode": { | ||
| "type": "string", | ||
| "enum": ["switch", "scroll"], | ||
| "default": "switch", | ||
| "description": "Display mode for upcoming fights" | ||
| } | ||
| } |
There was a problem hiding this comment.
Key mismatch between schema display_modes property names and code usage.
The schema defines toggle keys as show_live, show_recent, show_upcoming, but ufc_managers.py (Lines 49-51) reads them as ufc_live, ufc_recent, ufc_upcoming:
self.recent_enabled = display_modes.get("ufc_recent", False) # never matches schema key
self.upcoming_enabled = display_modes.get("ufc_upcoming", False)
self.live_enabled = display_modes.get("ufc_live", False)This means these toggles will always evaluate to False regardless of user config. Either the schema keys or the code keys need to be aligned.
🐛 Option A: Fix the schema to match the code
"show_live": {
+ "ufc_live": {
"type": "boolean",
"default": true,
"description": "Show live UFC fights"
},
- "show_recent": {
+ "ufc_recent": {
"type": "boolean",
"default": true,
"description": "Show recently completed UFC fights"
},
- "show_upcoming": {
+ "ufc_upcoming": {
"type": "boolean",
"default": true,
"description": "Show upcoming UFC fights"
},🤖 Prompt for AI Agents
In `@plugin-repos/ufc-scoreboard/config_schema.json` around lines 59 - 97, The
display toggle keys in the JSON schema ("show_live", "show_recent",
"show_upcoming") don't match the keys read in ufc_managers.py, so
self.recent_enabled, self.upcoming_enabled and self.live_enabled will always be
False; update the code that reads display_modes (the lines setting
self.recent_enabled, self.upcoming_enabled, self.live_enabled in the
ufc_managers.py initializer) to use the schema keys "show_recent",
"show_upcoming", and "show_live" (e.g., display_modes.get("show_recent", False)
etc.) so user-configured toggles take effect.
| # Filter for live games | ||
| live_events = [ | ||
| event for event in events | ||
| if event.get('competitions', [{}])[0] | ||
| .get('status', {}).get('type', {}).get('state') == 'in' | ||
| ] |
There was a problem hiding this comment.
IndexError risk when competitions is an empty list.
If an ESPN event has "competitions": [], then event.get('competitions', [{}])[0] will raise IndexError. The [{}] default only covers the case where the key is absent entirely.
🐛 Proposed fix: guard against empty competitions
# Filter for live games
live_events = [
event for event in events
- if event.get('competitions', [{}])[0]
- .get('status', {}).get('type', {}).get('state') == 'in'
+ if (event.get('competitions') or [{}])[0]
+ .get('status', {}).get('type', {}).get('state') == 'in'
]Using or [{}] handles both missing key and empty list cases.
🤖 Prompt for AI Agents
In `@plugin-repos/ufc-scoreboard/data_sources.py` around lines 83 - 88, The
filtering for live_events uses event.get('competitions', [{}])[0] which still
raises IndexError when competitions is an empty list; update the guard to handle
both missing and empty lists (e.g., use event.get('competitions') or [{}] before
indexing) or explicitly check truthiness/length of competitions; apply the
change in the live_events comprehension that references events and
event.get('competitions'...) so it safely obtains the first competition before
accessing .get('status')/.get('type')/.get('state').
| def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: | ||
| """Draw the layout for a recently completed MMA fight.""" | ||
| try: | ||
| main_img = Image.new( | ||
| "RGBA", (self.display_width, self.display_height), (0, 0, 0, 255) | ||
| ) | ||
| overlay = Image.new( | ||
| "RGBA", (self.display_width, self.display_height), (0, 0, 0, 0) | ||
| ) | ||
| draw_overlay = ImageDraw.Draw(overlay) | ||
|
|
||
| fighter1_image = self._load_and_resize_headshot( | ||
| game["fighter1_id"], | ||
| game["fighter1_name"], | ||
| game["fighter1_image_path"], | ||
| game["fighter1_image_url"], | ||
| ) | ||
| fighter2_image = self._load_and_resize_headshot( | ||
| game["fighter2_id"], | ||
| game["fighter2_name"], | ||
| game["fighter2_image_path"], | ||
| game["fighter2_image_url"], | ||
| ) | ||
|
|
||
| if not fighter1_image or not fighter2_image: | ||
| self.logger.error( | ||
| f"Failed to load headshots for fight: {game.get('id')}" | ||
| ) | ||
| draw_final = ImageDraw.Draw(main_img.convert("RGB")) | ||
| self._draw_text_with_outline( | ||
| draw_final, "Image Error", (5, 5), self.fonts["status"] | ||
| ) | ||
| self.display_manager.image.paste(main_img.convert("RGB"), (0, 0)) | ||
| self.display_manager.update_display() | ||
| return | ||
|
|
||
| center_y = self.display_height // 2 | ||
|
|
||
| # Fighter 1 (right side) headshot position | ||
| home_x = ( | ||
| self.display_width | ||
| - fighter1_image.width | ||
| + fighter1_image.width // 4 | ||
| + 2 | ||
| + self._get_layout_offset("home_logo", "x_offset") | ||
| ) | ||
| home_y = center_y - (fighter1_image.height // 2) + self._get_layout_offset("home_logo", "y_offset") | ||
| main_img.paste(fighter1_image, (home_x, home_y), fighter1_image) | ||
|
|
||
| # Fighter 2 (left side) headshot position | ||
| away_x = -2 - fighter2_image.width // 4 + self._get_layout_offset("away_logo", "x_offset") | ||
| away_y = center_y - (fighter2_image.height // 2) + self._get_layout_offset("away_logo", "y_offset") | ||
| main_img.paste(fighter2_image, (away_x, away_y), fighter2_image) | ||
|
|
||
| # Result text (centered bottom) | ||
| score_text = game.get("status_text", "Final") | ||
| score_width = draw_overlay.textlength(score_text, font=self.fonts["score"]) | ||
| score_x = (self.display_width - score_width) // 2 + self._get_layout_offset("score", "x_offset") | ||
| score_y = self.display_height - 14 + self._get_layout_offset("score", "y_offset") | ||
| self._draw_text_with_outline( | ||
| draw_overlay, score_text, (score_x, score_y), self.fonts["score"] | ||
| ) | ||
|
|
||
| # "Final" text (top center) | ||
| status_text = game.get("period_text", "Final") | ||
| status_width = draw_overlay.textlength(status_text, font=self.fonts["time"]) | ||
| status_x = (self.display_width - status_width) // 2 + self._get_layout_offset("status_text", "x_offset") | ||
| status_y = 1 + self._get_layout_offset("status_text", "y_offset") | ||
| self._draw_text_with_outline( | ||
| draw_overlay, status_text, (status_x, status_y), self.fonts["time"] | ||
| ) | ||
|
|
||
| if "odds" in game and game["odds"]: | ||
| self._draw_dynamic_odds( | ||
| draw_overlay, game["odds"], self.display_width, self.display_height | ||
| ) | ||
|
|
||
| # Draw records if enabled | ||
| if self.show_records: | ||
| try: | ||
| record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) | ||
| self.logger.debug("Loaded 6px record font successfully") | ||
| except IOError: | ||
| record_font = ImageFont.load_default() | ||
| self.logger.warning( | ||
| f"Failed to load 6px font, using default font (size: {record_font.size})" | ||
| ) | ||
|
|
||
| fighter1_record = game.get("fighter1_record", "") | ||
| fighter2_record = game.get("fighter2_record", "") | ||
|
|
||
| record_bbox = draw_overlay.textbbox((0, 0), "0-0-0", font=record_font) | ||
| record_height = record_bbox[3] - record_bbox[1] | ||
| record_y = self.display_height - record_height | ||
| self.logger.debug( | ||
| f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}" | ||
| ) | ||
|
|
||
| # Display fighter 2 record (left side) | ||
| if fighter2_record: | ||
| fighter2_text = fighter2_record | ||
| fighter2_record_x = 0 | ||
| self.logger.debug( | ||
| f"Drawing fighter2 record '{fighter2_text}' at ({fighter2_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" | ||
| ) | ||
| self._draw_text_with_outline( | ||
| draw_overlay, fighter2_text, (fighter2_record_x, record_y), record_font | ||
| ) | ||
|
|
||
| # Display fighter 1 record (right side) | ||
| if fighter1_record: | ||
| fighter1_text = fighter1_record | ||
| fighter1_record_bbox = draw_overlay.textbbox( | ||
| (0, 0), fighter1_text, font=record_font | ||
| ) | ||
| fighter1_record_width = fighter1_record_bbox[2] - fighter1_record_bbox[0] | ||
| fighter1_record_x = self.display_width - fighter1_record_width | ||
| self.logger.debug( | ||
| f"Drawing fighter1 record '{fighter1_text}' at ({fighter1_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" | ||
| ) | ||
| self._draw_text_with_outline( | ||
| draw_overlay, fighter1_text, (fighter1_record_x, record_y), record_font | ||
| ) | ||
|
|
||
| self._custom_scorebug_layout(game, draw_overlay) | ||
|
|
||
| # Composite and display | ||
| main_img = Image.alpha_composite(main_img, overlay) | ||
| main_img = main_img.convert("RGB") | ||
| self.display_manager.image.paste(main_img, (0, 0)) | ||
| self.display_manager.update_display() | ||
|
|
||
| except Exception as e: | ||
| self.logger.error( | ||
| f"Error displaying recent fight: {e}", exc_info=True | ||
| ) | ||
|
|
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Significant code duplication across _draw_scorebug_layout in all three subclasses.
The headshot loading, error handling, overlay compositing, record drawing, and odds drawing blocks are nearly identical across MMARecent, MMAUpcoming, and MMALive. This is ~350 lines of duplicated code. Consider extracting common rendering steps (headshot loading + error path, record rendering, overlay compositing) into the MMA base class.
Additionally, the record font is loaded via ImageFont.truetype() on every render call (lines 349, 697, 1009) instead of being cached. On a Raspberry Pi, this repeated I/O is wasteful. As per coding guidelines, "Optimize code for Raspberry Pi's limited RAM and CPU capabilities."
Also applies to: 587-752, 919-1074
🧰 Tools
🪛 Ruff (0.15.0)
[warning] 269-269: Unused method argument: force_clear
(ARG002)
[warning] 341-341: Unnecessary key check before dictionary access
Replace with dict.get
(RUF019)
🤖 Prompt for AI Agents
In `@plugin-repos/ufc-scoreboard/mma.py` around lines 269 - 405, The
_draw_scorebug_layout implementations duplicate headshot loading, error
handling, record/odds drawing and compositing across MMARecent, MMAUpcoming, and
MMALive; extract common work into MMA base-class helpers (e.g., add methods
_load_pair_headshots(game) returning (fighter1_image, fighter2_image),
_handle_headshot_error(game) to log and display the image error,
_draw_records(draw, game, record_font) to encapsulate the record rendering
logic, and _composite_and_display(main_img, overlay) to
alpha_composite/convert/paste/update); update each subclass's
_draw_scorebug_layout to call these helpers and keep only fight-specific layout
code, and cache the record font on the instance (load once in MMA.__init__ or as
a lazy property self.record_font) instead of calling ImageFont.truetype() on
every render to avoid repeated I/O on Raspberry Pi.
| def _test_mode_update(self): | ||
| if self.current_game and self.current_game["is_live"]: | ||
| minutes = int(self.current_game["clock"].split(":")[0]) | ||
| seconds = int(self.current_game["clock"].split(":")[1]) | ||
| seconds -= 1 | ||
| if seconds < 0: | ||
| seconds = 59 | ||
| minutes -= 1 | ||
| if minutes < 0: | ||
| minutes = 19 | ||
| if self.current_game["period"] < 3: | ||
| self.current_game["period"] += 1 | ||
| else: | ||
| self.current_game["period"] = 1 | ||
| self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}" |
There was a problem hiding this comment.
_test_mode_update uses "period" terminology — appears to be a leftover from team sports template.
MMA fights have rounds, not periods. The method references self.current_game["period"] and uses a max of 3 (hockey periods?) with rollover to 1. For MMA, this should reference round data. Additionally, accessing game["clock"] directly will KeyError if the field is missing.
🐛 Proposed fix sketch
def _test_mode_update(self):
- if self.current_game and self.current_game["is_live"]:
- minutes = int(self.current_game["clock"].split(":")[0])
- seconds = int(self.current_game["clock"].split(":")[1])
+ if self.current_game and self.current_game.get("is_live"):
+ clock = self.current_game.get("clock", "05:00")
+ minutes = int(clock.split(":")[0])
+ seconds = int(clock.split(":")[1])
seconds -= 1
if seconds < 0:
seconds = 59
minutes -= 1
if minutes < 0:
- minutes = 19
- if self.current_game["period"] < 3:
- self.current_game["period"] += 1
+ minutes = 4 # 5-minute MMA rounds
+ current_round = self.current_game.get("period", 1)
+ max_rounds = self.current_game.get("max_rounds", 3)
+ if current_round < max_rounds:
+ self.current_game["period"] = current_round + 1
else:
self.current_game["period"] = 1
self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}"🤖 Prompt for AI Agents
In `@plugin-repos/ufc-scoreboard/mma.py` around lines 903 - 917, The
_test_mode_update method incorrectly uses self.current_game["period"] and
assumes self.current_game["clock"] exists; change references from "period" to
"round" (use self.current_game.get("round", 1)) and determine max rounds from a
safe source (e.g., self.current_game.get("total_rounds", 3) or a class constant)
instead of hardcoding 3, and guard access to the clock by using
self.current_game.get("clock") (return early if missing or malformed) before
splitting; update the rollover logic to increment round up to max_rounds then
reset/stop as appropriate (use self.current_game["round"] = min(current_round+1,
max_rounds) or reset to 1 based on desired behavior) and ensure minutes/seconds
bounds are handled safely.
| new_width = int(separator_height * aspect) | ||
| ufc_icon = ufc_icon.resize( | ||
| (new_width, separator_height), Image.Resampling.LANCZOS | ||
| ) |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Image.Resampling Pillow 9.1.0 introduction date
💡 Result:
Image.Resampling was introduced in Pillow 9.1.0, released on April 1, 2022. [1], [2]
Sources:
[1] Pillow 9.1.0 on PyPI (release history) (pypi.org)
[2] Pillow 9.1.0 release notes (Deprecations → Constants / new IntEnum classes incl. Image.Resampling) (pillow.readthedocs.io)
Citations:
- 1: https://pypi.org/project/pillow/9.1.0/?utm_source=openai
- 2: https://pillow.readthedocs.io/en/stable/releasenotes/9.1.0.html?utm_source=openai
🏁 Script executed:
find . -type f \( -name "requirements*.txt" -o -name "setup.py" -o -name "setup.cfg" -o -name "pyproject.toml" -o -name "Pipfile" \) | head -20Repository: ChuckBuilds/LEDMatrix
Length of output: 191
🏁 Script executed:
rg -l "scroll_display.py|mma.py|fight_renderer.py" | head -5Repository: ChuckBuilds/LEDMatrix
Length of output: 145
🏁 Script executed:
fd -e py -path "*/ufc-scoreboard/*" | head -20Repository: ChuckBuilds/LEDMatrix
Length of output: 233
🏁 Script executed:
cat ./requirements.txtRepository: ChuckBuilds/LEDMatrix
Length of output: 1206
🏁 Script executed:
cat ./plugin-repos/ufc-scoreboard/requirements.txtRepository: ChuckBuilds/LEDMatrix
Length of output: 280
🏁 Script executed:
find ./plugin-repos/ufc-scoreboard -name "*.py" | head -20Repository: ChuckBuilds/LEDMatrix
Length of output: 533
🏁 Script executed:
rg "Image\.Resampling|Image\.LANCZOS" --type pyRepository: ChuckBuilds/LEDMatrix
Length of output: 1137
🏁 Script executed:
sed -n '155,165p' ./plugin-repos/ufc-scoreboard/scroll_display.pyRepository: ChuckBuilds/LEDMatrix
Length of output: 651
🏁 Script executed:
sed -n '105,115p' ./plugin-repos/ufc-scoreboard/mma.pyRepository: ChuckBuilds/LEDMatrix
Length of output: 479
🏁 Script executed:
sed -n '158,168p' ./plugin-repos/ufc-scoreboard/fight_renderer.pyRepository: ChuckBuilds/LEDMatrix
Length of output: 588
Image.Resampling.LANCZOS does not exist in Pillow 8.0.0 — will raise AttributeError at runtime.
The plugin's requirements.txt allows Pillow 8.0.0, but Image.Resampling was introduced in Pillow 9.1.0. Use Image.LANCZOS instead, which is available in both versions.
This issue appears in at least 9 files, not just the three mentioned:
plugin-repos/ufc-scoreboard/scroll_display.py(line 162)plugin-repos/ufc-scoreboard/mma.py(line 111)plugin-repos/ufc-scoreboard/fight_renderer.py(line 163)plugin-repos/ufc-scoreboard/sports.pysrc/base_classes/sports.pysrc/common/logo_helper.pysrc/vegas_mode/plugin_adapter.py(appears twice)src/image_utils.py(appears twice)
🤖 Prompt for AI Agents
In `@plugin-repos/ufc-scoreboard/scroll_display.py` around lines 160 - 163,
Several places call Image.Resampling.LANCZOS (e.g., the ufc_icon.resize call)
which raises AttributeError on Pillow 8.x; replace usages with Image.LANCZOS (or
use a small compatibility helper like resample = getattr(Image, "Resampling",
Image); resample.LANCZOS) and update all occurrences (e.g., in the
ufc_icon.resize in scroll_display.py, the resize in mma.py and
fight_renderer.py, and similar calls in sports.py, logo_helper.py,
plugin_adapter.py, and image_utils.py) so the code works with Pillow 8.0.0 and
newer.
| def reset(self) -> None: | ||
| """Reset scroll state.""" | ||
| self._is_scrolling = False | ||
| self._current_fights = [] | ||
| self._frame_count = 0 | ||
| if self.scroll_helper: | ||
| self.scroll_helper.reset() |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Find the ScrollHelper class definition
rg -n 'class ScrollHelper' --type=py
# Search for reset method in ScrollHelper or related files
rg -n 'def reset' --type=pyRepository: ChuckBuilds/LEDMatrix
Length of output: 1244
🏁 Script executed:
# Read the ScrollHelper class to see all methods
cat -n src/common/scroll_helper.py | head -100
# Also search for all methods in ScrollHelper
rg -n 'def ' src/common/scroll_helper.pyRepository: ChuckBuilds/LEDMatrix
Length of output: 6202
🏁 Script executed:
# Check if reset_scroll does exist and what it does
sed -n '628,650p' src/common/scroll_helper.py
# Check if there's any alias or property named 'reset'
rg -n 'reset\s*=' src/common/scroll_helper.pyRepository: ChuckBuilds/LEDMatrix
Length of output: 1077
🏁 Script executed:
# Final verification: check the entire ScrollHelper class for any reset() method
grep -n "def reset" src/common/scroll_helper.py
# Also check the end of the file to ensure we have the complete class
tail -50 src/common/scroll_helper.pyRepository: ChuckBuilds/LEDMatrix
Length of output: 2240
Fix method name: ScrollHelper does not have reset(), only reset_scroll().
ScrollHelper at src/common/scroll_helper.py exposes only reset_scroll() (line 628), not reset(). Calling self.scroll_helper.reset() on line 365 will raise AttributeError at runtime. Change to self.scroll_helper.reset_scroll().
🤖 Prompt for AI Agents
In `@plugin-repos/ufc-scoreboard/scroll_display.py` around lines 365 - 371, The
reset method in scroll_display.py calls a non-existent ScrollHelper.reset(),
which will raise AttributeError; change the call in the reset method to use the
actual ScrollHelper API by invoking self.scroll_helper.reset_scroll() instead
(update the reset method where self._is_scrolling, self._current_fights, and
self._frame_count are set so it calls self.scroll_helper.reset_scroll()).
| if result.success: | ||
| self.logger.info( | ||
| f"Background fetch completed for {season_year}: " | ||
| f"{len(result.data.get('events'))} events" | ||
| ) | ||
| else: | ||
| self.logger.error( | ||
| f"Background fetch failed for {season_year}: {result.error}" | ||
| ) |
There was a problem hiding this comment.
len(result.data.get('events')) will raise TypeError if 'events' key is missing.
In the fetch_callback (Line 122), result.data.get('events') returns None when the key is absent, and len(None) raises TypeError, crashing the callback.
🐛 Proposed fix
if result.success:
self.logger.info(
f"Background fetch completed for {season_year}: "
- f"{len(result.data.get('events'))} events"
+ f"{len(result.data.get('events', []))} events"
)🤖 Prompt for AI Agents
In `@plugin-repos/ufc-scoreboard/ufc_managers.py` around lines 119 - 127, In
fetch_callback, calling len(result.data.get('events')) can raise TypeError if
result.data is None or 'events' is missing; instead, safely obtain events via
something like events = (result.data or {}).get('events') or [] and log
len(events). Update the success branch (where self.logger.info is called) to
compute events this way before formatting the message so it never calls
len(None) and still reports zero when events are absent.
Summary
ufc_live,ufc_recent,ufc_upcomingcustomization.layoutconfig (x/y offsets for all display elements)Files (14 total, ~7200 lines)
manifest.jsonconfig_schema.jsonmanager.pyUFCScoreboardPluginorchestratorsports.pymma.pyufc_managers.pyfight_renderer.pyscroll_display.pydata_sources.pybase_odds_manager.pyhomeAthleteOdds/awayAthleteOdds)headshot_downloader.pygenerate_placeholder_icon.pyassets/sports/ufc_logos/UFC.pngrequirements.txtBug fixes from PR #137
home_id/away_id) replaced with fighter fields (fighter1_id/fighter2_id)sport="nfl"in background fetch changed to"mma"show_shots_on_goalcode"Some score here"placeholder with actual fight result datafavorite_teams→favorite_fightersthroughoutfavorite_games_found = 0before loopaway_text/away_record_x_custom_scorebug_layoutmethod"weigth_class"typo →"favorite_weight_classes"Credit
Alex Resnick (@legoguy1000) is credited in manifest.json contributors, code headers, and plugin description for the original UFC/MMA implementation in PR #137.
Test plan
🤖 Generated with Claude Code
Summary by CodeRabbit