Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions plugins.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": "1.0.0",
"last_updated": "2026-07-30",
"last_updated": "2026-07-31",
"plugins": [
{
"id": "cricket-scoreboard",
Expand Down Expand Up @@ -732,10 +732,10 @@
"plugin_path": "plugins/soccer-scoreboard",
"stars": 0,
"downloads": 0,
"last_updated": "2026-07-17",
"last_updated": "2026-07-31",
"verified": true,
"screenshot": "",
"latest_version": "2.4.0"
"latest_version": "2.4.1"
},
{
"id": "static-image",
Expand Down
27 changes: 26 additions & 1 deletion plugins/soccer-scoreboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,32 @@ The plugin supports the following soccer leagues:
- **uefa.europa**: UEFA Europa League
- **fifa.world**: FIFA World Cup

Additional leagues can be added via the **Custom Leagues** setting using any ESPN soccer league code (e.g. `mex.1`, `arg.1`, `bra.1`, `ned.1`).
### Adding another league

Any other league ESPN covers can be added under **Add More Leagues** in the plugin
settings. Click **Add Item**, then fill in **both** a display name and the ESPN
league code — a row with a blank name will not save.

Common codes:

| Code | League |
| --- | --- |
| `eng.2` | English Championship |
| `eng.3` | English League One |
| `eng.fa` | FA Cup |
| `eng.league_cup` | EFL (Carabao) Cup |
| `mex.1` | Liga MX |
| `arg.1` | Argentine Primera División |
| `bra.1` | Brasileirão Série A |
| `ned.1` | Eredivisie |
| `sco.1` | Scottish Premiership |
| `tur.1` | Turkish Süper Lig |
| `bel.1` | Belgian Pro League |
| `conmebol.libertadores` | Copa Libertadores |

Codes are lowercase and dot-separated, exactly as they appear in ESPN's own URLs
(`espn.com/soccer/scoreboard/_/league/eng.2`). Per-league favorites, durations,
and display modes live behind the ⚙ button on the league's row.

## FIFA World Cup

Expand Down
48 changes: 24 additions & 24 deletions plugins/soccer-scoreboard/config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -4247,7 +4247,7 @@
"custom_leagues": {
"type": "array",
"title": "Add More Leagues",
"description": "Add any soccer league available on ESPN. Click 'Add Item' then enter a league code. Common codes: mex.1 (Liga MX), arg.1 (Argentina), bra.1 (Brazil), ned.1 (Eredivisie), sco.1 (Scottish Premiership), tur.1 (Turkish Süper Lig), bel.1 (Belgian Pro League)",
"description": "Add any soccer league available on ESPN. Click 'Add Item', then fill in BOTH a name and a league code. Common codes: eng.2 (English Championship), eng.3 (League One), eng.fa (FA Cup), eng.league_cup (EFL Cup), mex.1 (Liga MX), arg.1 (Argentina), bra.1 (Brazil), ned.1 (Eredivisie), sco.1 (Scottish Premiership), tur.1 (Turkish Süper Lig), bel.1 (Belgian Pro League)",
"x-widget": "array-table",
"x-columns": [
"name",
Expand All @@ -4271,11 +4271,11 @@
"type": "string",
"minLength": 1,
"maxLength": 50,
"pattern": "^[a-z]{2,4}(\\.[a-z0-9]+)+$",
"description": "ESPN league code in dot-separated format (e.g., 'por.1', 'mex.1', 'arg.1', 'uefa.champions')"
"pattern": "^[a-z][a-z0-9_-]*(\\.[a-z0-9_-]+)+$",
"description": "ESPN league code in dot-separated format (e.g., 'eng.2', 'mex.1', 'uefa.champions', 'eng.league_cup', 'conmebol.libertadores')"
},
"priority": {
"type": "integer",
"type": ["integer", "null"],
"default": 50,
"minimum": 1,
"maximum": 100,
Expand All @@ -4287,25 +4287,25 @@
"description": "Whether this league is enabled"
},
"favorite_teams": {
"type": "array",
"type": ["array", "string", "null"],
"items": {
"type": "string"
},
"default": [],
"uniqueItems": true,
"maxItems": 20,
"description": "Favorite team abbreviations for this league"
"description": "Favorite team abbreviations for this league (comma-separated when typed into the row editor)"
},
"exclude_teams": {
"type": "array",
"type": ["array", "string", "null"],
"items": {
"type": "string",
"description": "Custom league team name or abbreviation"
},
"uniqueItems": true,
"maxItems": 20,
"default": [],
"description": "Team abbreviations to always hide from live rotation and recent/final scores for this league (spoiler protection). Takes precedence over Favorite Teams and Show All Live.",
"description": "Team abbreviations to always hide from live rotation and recent/final scores for this league (spoiler protection). Takes precedence over Favorite Teams and Show All Live. Comma-separated when typed into the row editor.",
"x-advanced": true
},
"live_priority": {
Expand Down Expand Up @@ -4368,31 +4368,31 @@
"additionalProperties": false
},
"live_game_duration": {
"type": "integer",
"type": ["integer", "null"],
"default": 20,
"minimum": 10,
"maximum": 120,
"description": "Duration in seconds to display each live game",
"x-advanced": true
},
"non_favorite_live_game_duration": {
"type": "integer",
"type": ["integer", "null"],
"default": 0,
"minimum": 0,
"maximum": 120,
"description": "Duration in seconds for live games that do NOT involve a favorite team. Only applies when favorite teams are set AND non-favorite live games are shown ('show_favorite_teams_only' off, or 'show_all_live' on). 0 (default) = use live_game_duration for every live game (no change).",
"x-advanced": true
},
"recent_game_duration": {
"type": "integer",
"type": ["integer", "null"],
"default": 15,
"minimum": 5,
"maximum": 60,
"description": "Duration in seconds to display each recent game",
"x-advanced": true
},
"upcoming_game_duration": {
"type": "integer",
"type": ["integer", "null"],
"default": 15,
"minimum": 5,
"maximum": 60,
Expand All @@ -4405,14 +4405,14 @@
"description": "Control how many games to show",
"properties": {
"recent_games_to_show": {
"type": "integer",
"type": ["integer", "null"],
"default": 1,
"minimum": 1,
"maximum": 20,
"description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time."
},
"upcoming_games_to_show": {
"type": "integer",
"type": ["integer", "null"],
"default": 10,
"minimum": 1,
"maximum": 20,
Expand All @@ -4437,7 +4437,7 @@
"description": "Show all live games, not just favorites"
},
"favorite_live_boost": {
"type": "integer",
"type": ["integer", "null"],
"minimum": 1,
"maximum": 5,
"default": 2,
Expand All @@ -4459,22 +4459,22 @@
"x-advanced": true
},
"min_duration_seconds": {
"type": "number",
"type": ["number", "null"],
"minimum": 10,
"maximum": 300,
"default": 30,
"description": "Minimum total duration in seconds",
"x-advanced": true
},
"max_duration_seconds": {
"type": "number",
"type": ["number", "null"],
"minimum": 60,
"maximum": 600,
"description": "Maximum total duration in seconds",
"x-advanced": true
},
"modes": {
"type": "object",
"type": ["object", "null"],
"title": "Per-Mode Settings",
"description": "Configure dynamic duration for specific modes",
"properties": {
Expand All @@ -4488,13 +4488,13 @@
"x-advanced": true
},
"min_duration_seconds": {
"type": "number",
"type": ["number", "null"],
"minimum": 10,
"maximum": 300,
"x-advanced": true
},
"max_duration_seconds": {
"type": "number",
"type": ["number", "null"],
"minimum": 60,
"maximum": 600,
"x-advanced": true
Expand All @@ -4512,13 +4512,13 @@
"x-advanced": true
},
"min_duration_seconds": {
"type": "number",
"type": ["number", "null"],
"minimum": 10,
"maximum": 300,
"x-advanced": true
},
"max_duration_seconds": {
"type": "number",
"type": ["number", "null"],
"minimum": 60,
"maximum": 600,
"x-advanced": true
Expand All @@ -4536,13 +4536,13 @@
"x-advanced": true
},
"min_duration_seconds": {
"type": "number",
"type": ["number", "null"],
"minimum": 10,
"maximum": 300,
"x-advanced": true
},
"max_duration_seconds": {
"type": "number",
"type": ["number", "null"],
"minimum": 60,
"maximum": 600,
"x-advanced": true
Expand Down
45 changes: 43 additions & 2 deletions plugins/soccer-scoreboard/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -454,10 +454,50 @@ def _build_custom_league_map(self) -> None:
"""Build O(1) lookup map from custom_leagues config, keyed by league_code."""
self._custom_league_map: Dict[str, Dict] = {
cl['league_code']: cl
for cl in self.config.get('custom_leagues', [])
if cl.get('league_code')
for cl in (self.config.get('custom_leagues') or [])
if isinstance(cl, dict) and cl.get('league_code')
}

def _normalize_custom_leagues(self) -> None:
"""
Clean up the custom_leagues config in place before anything reads it.

The web UI's array-table editor keeps every non-column property in a
hidden text input, so anything it can't represent as text comes back as
null (empty input), and list properties come back as the comma-separated
string the user typed. Dropping the nulls lets every downstream
``.get(key, default)`` fall back to its default, and splitting the
strings restores the list shape the managers expect.
"""
custom_leagues = self.config.get('custom_leagues') or []

def _strip_nulls(value: Any) -> Any:
if isinstance(value, dict):
return {k: _strip_nulls(v) for k, v in value.items() if v is not None}
return value

normalized: List[Dict[str, Any]] = []
for custom_league in custom_leagues:
if not isinstance(custom_league, dict):
self.logger.warning("Skipping malformed custom league entry: %r", custom_league)
continue

cleaned = _strip_nulls(custom_league)

# "ARS, CHE" (row editor) -> ["ARS", "CHE"]
for key in ('favorite_teams', 'exclude_teams'):
teams = cleaned.get(key)
if isinstance(teams, str):
cleaned[key] = [t.strip() for t in teams.split(',') if t.strip()]

code = cleaned.get('league_code')
if isinstance(code, str):
cleaned['league_code'] = code.strip().lower()

normalized.append(cleaned)

self.config['custom_leagues'] = normalized

def _get_league_config(self, league_key: str, league_data: Optional[Dict] = None) -> Dict:
"""Get the config dict for a league, handling both predefined and custom leagues."""
if league_data is None:
Expand All @@ -482,6 +522,7 @@ def _load_custom_leagues(self) -> None:
3. Updates league_enabled and league_live_priority dicts
4. Updates LEAGUE_NAMES for display purposes
"""
self._normalize_custom_leagues()
custom_leagues = self.config.get('custom_leagues', [])

if not custom_leagues:
Expand Down
12 changes: 9 additions & 3 deletions plugins/soccer-scoreboard/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "soccer-scoreboard",
"name": "Soccer Scoreboard",
"version": "2.4.0",
"version": "2.4.1",
"author": "ChuckBuilds",
"description": "Live, recent, and upcoming soccer games across multiple leagues including Premier League, La Liga, Bundesliga, Serie A, Ligue 1, MLS, Liga Portugal, Champions League, Europa League, and FIFA World Cup",
"category": "sports",
Expand All @@ -26,6 +26,12 @@
"soccer_upcoming"
],
"versions": [
{
"released": "2026-07-31",
"version": "2.4.1",
"notes": "Fix adding a custom league (e.g. the English Championship, eng.2) failing to save with HTTP 400. The web UI's row editor sends unset list/object properties as null and typed team lists as comma-separated text, which the strict array/object/number types in custom_leagues rejected, so the whole config save was blocked no matter which league code was entered. Those properties now accept null (and comma-separated text for team lists), and the plugin normalizes them back to defaults on load. The league_code pattern also no longer rejects valid ESPN codes with underscores or long prefixes (eng.league_cup, conmebol.libertadores).",
"ledmatrix_min": "2.0.0"
},
{
"released": "2026-07-29",
"version": "2.4.0",
Expand Down Expand Up @@ -136,7 +142,7 @@
"ledmatrix_min_version": "2.0.0"
}
],
"last_updated": "2026-07-17",
"last_updated": "2026-07-31",
"stars": 0,
"downloads": 0,
"verified": true,
Expand All @@ -153,4 +159,4 @@
"compatible_versions": [
">=2.0.0"
]
}
}
Loading
Loading