Skip to content

feat(plugins): add UFC scoreboard plugin#245

Closed
ChuckBuilds wants to merge 1 commit intomainfrom
feat/ufc-scoreboard-plugin
Closed

feat(plugins): add UFC scoreboard plugin#245
ChuckBuilds wants to merge 1 commit intomainfrom
feat/ufc-scoreboard-plugin

Conversation

@ChuckBuilds
Copy link
Copy Markdown
Owner

@ChuckBuilds ChuckBuilds commented Feb 13, 2026

Summary

  • Converts PR Add UFC #137 (by Alex Resnick / @legoguy1000) into a standalone plugin following the BasePlugin architecture
  • Adds UFC/MMA scoreboard with 3 display modes: ufc_live, ufc_recent, ufc_upcoming
  • Includes fighter headshots from ESPN CDN, records, MMA athlete odds, fight results, and weight class display
  • Supports vegas scroll mode (high-FPS horizontal scrolling of all fights with UFC separator icons)
  • Supports dynamic duration with per-mode min/max caps
  • User-adjustable element positioning via customization.layout config (x/y offsets for all display elements)
  • Comprehensive JSON Schema Draft-7 config with favorite fighters, weight class filtering, display options
  • Live priority support to interrupt rotation when UFC fights are active

Files (14 total, ~7200 lines)

File Purpose
manifest.json Plugin metadata, credits legoguy1000
config_schema.json Comprehensive config schema
manager.py UFCScoreboardPlugin orchestrator
sports.py SportsCore base classes
mma.py MMA base classes (16 bugs fixed from PR #137)
ufc_managers.py UFC Live/Recent/Upcoming managers
fight_renderer.py PIL rendering for fight cards
scroll_display.py Vegas scroll support with ScrollHelper
data_sources.py ESPN API integration
base_odds_manager.py MMA athlete odds (homeAthleteOdds/awayAthleteOdds)
headshot_downloader.py Fighter headshot download + caching
generate_placeholder_icon.py Creates placeholder UFC octagon icon
assets/sports/ufc_logos/UFC.png UFC separator icon for scroll display
requirements.txt Empty (deps provided by core)

Bug fixes from PR #137

  • Team-sport field names (home_id/away_id) replaced with fighter fields (fighter1_id/fighter2_id)
  • sport="nfl" in background fetch changed to "mma"
  • Removed hockey-specific show_shots_on_goal code
  • Replaced "Some score here" placeholder with actual fight result data
  • Fixed favorite_teamsfavorite_fighters throughout
  • Initialized favorite_games_found = 0 before loop
  • Fixed variable scoping for away_text/away_record_x
  • Added missing _custom_scorebug_layout method
  • Added layout offset support to all position calculations
  • Fixed "weigth_class" typo → "favorite_weight_classes"
  • Replaced football test data in UFCLiveManager with fighter data
  • Added synchronous fallback when background service unavailable

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

  • Verify plugin loads without import errors
  • Verify config schema renders correctly in web UI
  • Test with live ESPN MMA API responses
  • Verify all 3 display modes render correctly (live/recent/upcoming)
  • Test element positioning adjustments via config
  • Test vegas scroll mode with multiple fights
  • Test fighter headshot download and caching
  • Test favorite fighters filtering
  • Test dynamic duration cycle tracking

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • UFC/MMA scoreboard plugin with live, recent, and upcoming fight displays
    • Customizable fighter cards featuring headshots, records, and betting odds
    • Horizontal scrolling display for cycling through multiple fights
    • Fighter favorites filtering and dynamic display duration settings
    • Extensive customization options for fonts, layout, and display modes

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>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 13, 2026

📝 Walkthrough

Walkthrough

This 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

Cohort / File(s) Summary
Core Plugin Management
manager.py, manifest.json
Introduces UFCScoreboardPlugin class orchestrating UFC display modes (live, recent, upcoming) with dynamic duration, Vegas integration, cycle management, and scroll display support. Manifest declares plugin metadata, versioning, and configuration schema location.
Configuration & Schema
config_schema.json, requirements.txt
Defines JSON Schema for UFC plugin configuration including display modes, timing, filters, customization (fonts, layout offsets), and dynamic duration settings. Requirements file specifies core dependencies: requests, pytz, Pillow.
UFC Manager Classes
ufc_managers.py, mma.py
Implements UFC-specific manager hierarchy (BaseUFCManager, UFCLiveManager, UFCRecentManager, UFCUpcomingManager) with data fetching, caching, and display-mode routing. MMA base class provides common fight extraction, headshot caching, and scorebug rendering logic for Recent/Upcoming/Live variants.
Data Sources & Odds
data_sources.py, base_odds_manager.py
Introduces pluggable DataSource framework with ESPN implementation for fetching live games, schedules, and standings. BaseOddsManager handles ESPN odds fetching, caching, API counter integration, and normalization of team/athlete-based odds data.
Fight Card Rendering
fight_renderer.py, scroll_display.py
FightRenderer class renders individual UFC fight cards as PIL images with configurable fonts, layout offsets, headshots, and optional fighter names/records/odds. ScrollDisplayManager handles high-FPS horizontal scrolling composition of multiple fight cards with league separators.
Utility Modules
headshot_downloader.py, generate_placeholder_icon.py
HeadshotDownloader class manages fighter image provisioning with retry logic, placeholder fallback, and RGBA normalization. Placeholder icon generator creates UFC octagon-style graphics with text rendering fallbacks.

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
Loading
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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🥋 A scoreboard born in code today,
UFC fights in bright display,
With headshots, odds, and scroll so fast,
The rabbit codes—this fight will last! 🐰✨

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (2 files):

⚔️ web_interface/static/v3/app.css (content)
⚔️ web_interface/templates/v3/partials/logs.html (content)

These conflicts must be resolved before merging into main.
Resolve conflicts locally and push changes to this branch.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title 'feat(plugins): add UFC scoreboard plugin' directly and accurately describes the main change: introducing a new UFC scoreboard plugin with comprehensive support for MMA event display across multiple modes.
Docstring Coverage ✅ Passed Docstring coverage is 81.65% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/ufc-scoreboard-plugin
⚔️ Resolve merge conflicts (beta)
  • Auto-commit resolved conflicts to branch feat/ufc-scoreboard-plugin
  • Create stacked PR with resolved conflicts
  • Post resolved changes as copyable diffs in a comment

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@ChuckBuilds
Copy link
Copy Markdown
Owner Author

Wrong repo - this plugin belongs in the ledmatrix-plugins monorepo

@ChuckBuilds ChuckBuilds deleted the feat/ufc-scoreboard-plugin branch February 13, 2026 04:13
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟡 Minor

Inconsistent types and missing constraints across per-mode duration fields.

live_game_duration (Line 105) uses "type": "integer" with minimum/maximum constraints, while recent_game_duration (Line 112) and upcoming_game_duration (Line 117) use "type": "number" and lack minimum/maximum bounds. 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 in ufc_managers.py.

Line 69 uses datetime.now() (local time), while ufc_managers.py Line 68 uses datetime.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 timezone to the import on Line 12:

-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
plugin-repos/ufc-scoreboard/ufc_managers.py-22-27 (1)

22-27: ⚠️ Potential issue | 🟡 Minor

Remove 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_time variable (line 24) is used by the parent SportsCore class for warning cooldown tracking and should be kept as an instance variable, not a class variable—the parent class already handles this with defensive hasattr/setattr checks.

plugin-repos/ufc-scoreboard/manager.py-706-706 (1)

706-706: ⚠️ Potential issue | 🟡 Minor

f-string without any placeholders — remove the f prefix.

🐛 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 | 🟡 Minor

120-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.py module 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 raise ValueError if 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 default
plugin-repos/ufc-scoreboard/manager.py-578-620 (1)

578-620: ⚠️ Potential issue | 🟡 Minor

Unused variable and silently swallowed exception.

  1. Line 580: manager_class_name is assigned but never used.
  2. Lines 619-620: The bare except: pass silently 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 | 🟡 Minor

Duplicate condition check and incorrect not ... in syntax.

Line 788 and 790 both check game and game["is_upcoming"]. The second check is redundant. Additionally, line 801-802 uses not (...) in which should be not in per 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: catching OSError would be more precise, but the fallback chain is acceptable here.

The static analysis flags Exception catches on Lines 34 and 37. For font loading, PIL raises OSError (or IOError) 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 path assets/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 using Path(__file__).parent to resolve the path relative to the script.

plugin-repos/ufc-scoreboard/data_sources.py (1)

13-13: Unused import: time.

The time module is imported but never used in this file.

♻️ Remove unused import
-import time
plugin-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 using os.access() as a lighter check or just attempting the write directly and handling PermissionError.

plugin-repos/ufc-scoreboard/base_odds_manager.py (2)

140-148: Raw requests.get() without retry strategy; verbose JSON dump at debug level.

Line 140 uses requests.get() directly with no session or retry setup, unlike DataSource and HeadshotDownloader which 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() without strict=True will silently truncate if event_ids and comp_ids differ 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 isinstance in 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_data in 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: Redundant set_scroll_speed call — first value is immediately overwritten.

set_scroll_speed is called on line 102 with the raw scroll_speed (e.g. 50.0), then overwritten on line 124 with the clamped pixels_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 explicit Optional type hint for rankings_cache.

PEP 484 prohibits implicit Optional. Using None as a default without Optional annotation 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: New FightRenderer is instantiated on every prepare_and_display call.

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_renderer
plugin-repos/ufc-scoreboard/mma.py (2)

124-128: Direct key access on API data can raise KeyError/IndexError if ESPN response is malformed.

game_event["competitions"][0], competition["status"], and competition["competitors"] use direct indexing. While wrapped in a broad except 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 the with block but file handle behavior is safe here.

img.save(image_path, "PNG") on line 84 works correctly within the context manager. However, immediately after the with block 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 explicit Optional type hints for parameters defaulting to None.

headshot_url: str = None should be headshot_url: Optional[str] = None. Same applies to fight_type and display_options in render_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 after super().__init__() may override base class setup.

Lines 64-68 unconditionally reassign plugin_id, config, display_manager, cache_manager, and plugin_manager — attributes that BasePlugin.__init__() likely already sets. This is harmless when BasePlugin is available (same values), but the pattern is confusing. Consider guarding the reassignment with if 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}")

Comment on lines +107 to +118
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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_data

And 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.

Suggested change
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.

Comment on lines +166 to +173
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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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.

Comment on lines +276 to +284
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")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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).

Comment on lines +59 to +97
"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"
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Comment on lines +83 to +88
# Filter for live games
live_events = [
event for event in events
if event.get('competitions', [{}])[0]
.get('status', {}).get('type', {}).get('state') == 'in'
]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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').

Comment on lines +269 to +405
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
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Comment on lines +903 to +917
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}"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

_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.

Comment on lines +160 to +163
new_width = int(separator_height * aspect)
ufc_icon = ufc_icon.resize(
(new_width, separator_height), Image.Resampling.LANCZOS
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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:


🏁 Script executed:

find . -type f \( -name "requirements*.txt" -o -name "setup.py" -o -name "setup.cfg" -o -name "pyproject.toml" -o -name "Pipfile" \) | head -20

Repository: ChuckBuilds/LEDMatrix

Length of output: 191


🏁 Script executed:

rg -l "scroll_display.py|mma.py|fight_renderer.py" | head -5

Repository: ChuckBuilds/LEDMatrix

Length of output: 145


🏁 Script executed:

fd -e py -path "*/ufc-scoreboard/*" | head -20

Repository: ChuckBuilds/LEDMatrix

Length of output: 233


🏁 Script executed:

cat ./requirements.txt

Repository: ChuckBuilds/LEDMatrix

Length of output: 1206


🏁 Script executed:

cat ./plugin-repos/ufc-scoreboard/requirements.txt

Repository: ChuckBuilds/LEDMatrix

Length of output: 280


🏁 Script executed:

find ./plugin-repos/ufc-scoreboard -name "*.py" | head -20

Repository: ChuckBuilds/LEDMatrix

Length of output: 533


🏁 Script executed:

rg "Image\.Resampling|Image\.LANCZOS" --type py

Repository: ChuckBuilds/LEDMatrix

Length of output: 1137


🏁 Script executed:

sed -n '155,165p' ./plugin-repos/ufc-scoreboard/scroll_display.py

Repository: ChuckBuilds/LEDMatrix

Length of output: 651


🏁 Script executed:

sed -n '105,115p' ./plugin-repos/ufc-scoreboard/mma.py

Repository: ChuckBuilds/LEDMatrix

Length of output: 479


🏁 Script executed:

sed -n '158,168p' ./plugin-repos/ufc-scoreboard/fight_renderer.py

Repository: 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.py
  • src/base_classes/sports.py
  • src/common/logo_helper.py
  • src/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.

Comment on lines +365 to +371
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()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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=py

Repository: 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.py

Repository: 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.py

Repository: 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.py

Repository: 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()).

Comment on lines +119 to +127
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}"
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant