Merge development into main

- Resolved conflicts in src/logo_downloader.py:
  - Combined NCAA hockey endpoint with soccer league endpoints
  - Updated directory mappings to use ncaa_logos for NCAA sports
  - Added support for multiple soccer leagues (Premier League, La Liga, etc.)

- Resolved conflicts in src/ncaa_fb_managers.py:
  - Combined immediate fetch approach with background fetch strategy
  - Maintained both immediate response and comprehensive data fetching
  - Preserved caching functionality for improved performance

- Includes all development branch features:
  - Soccer league support with team logos
  - Enhanced NCAA football data fetching
  - Improved logo downloader with multiple league support
  - Updated wiki documentation and configuration
This commit is contained in:
Chuck
2025-09-16 15:37:59 -04:00
149 changed files with 2790 additions and 7221 deletions

2
.gitignore vendored
View File

@@ -5,6 +5,8 @@ __pycache__/
# Secrets
config/config_secrets.json
config/config.json
config/config.json.backup
credentials.json
token.pickle

Submodule LEDMatrix.wiki updated: 8d2c143954...d85658a2eb

View File

@@ -59,7 +59,7 @@ The system supports live, recent, and upcoming game information for multiple spo
- NCAA Football
- NCAA Men's Basketball
- NCAA Men's Baseball
- Soccer
- Soccer (Premier League, La Liga, Bundesliga, Serie A, Ligue 1, Liga Portugal, Champions League, Europa League, MLS)
- (Note, some of these sports seasons were not active during development and might need fine tuning when games are active)
@@ -260,16 +260,38 @@ sudo reboot
## Configuration
1.Edit `config/config.json` with your preferences via `sudo nano config/config.json`
### Initial Setup
###API Keys
The system uses a template-based configuration approach to avoid Git conflicts during updates:
1. **First-time setup**: Copy the template to create your config:
```bash
cp config/config.template.json config/config.json
```
2. **Edit your configuration**:
```bash
sudo nano config/config.json
```
or edit via web interface at http://ledpi:5001
### API Keys and Secrets
For sensitive settings like API keys:
Copy the template: `cp config/config_secrets.template.json config/config_secrets.json`
Edit `config/config_secrets.json` with your API keys via `sudo nano config/config_secrets.json`
Ctrl + X to exit, Y to overwrite, Enter to Confirm
1. Copy the secrets template: `cp config/config_secrets.template.json config/config_secrets.json`
2. Edit `config/config_secrets.json` with your API keys via `sudo nano config/config_secrets.json`
3. Ctrl + X to exit, Y to overwrite, Enter to Confirm
Everything is configured via `config/config.json` and `config/config_secrets.json`.
### Automatic Configuration Migration
The system automatically handles configuration updates:
- **New installations**: Creates `config.json` from the template automatically
- **Existing installations**: Automatically adds new configuration options with default values when the system starts
- **Backup protection**: Creates a backup of your current config before applying updates
- **No conflicts**: Your custom settings are preserved while new options are added
Everything is configured via `config/config.json` and `config/config_secrets.json`. The `config.json` file is not tracked by Git to prevent conflicts during updates.

View File

@@ -755,6 +755,20 @@ MLB Conferences/Divisions
OAK => Oakland Athletics
SEA => Seattle Mariners
TEX => Texas Rangers
Soccer Leagues:
LEAGUE_SLUGS = {
"eng.1": "Premier League",
"esp.1": "La Liga",
"ger.1": "Bundesliga",
"ita.1": "Serie A",
"fra.1": "Ligue 1",
"uefa.champions": "Champions League",
"uefa.europa": "Europa League",
"usa.1": "MLS",
"por.1": "Liga Portugal", # Add this line
}
Soccer - Premier League (England)
ARS => Arsenal
AVL => Aston Villa
@@ -886,6 +900,24 @@ Soccer - Champions League
VFB => VfB Stuttgart
VIL => Villarreal
Soccer - Liga Portugal (Portugal)
ARO => Arouca
BEN => SL Benfica
BRA => SC Braga
CHA => Chaves
EST => Estoril Praia
FAM => Famalicão
GIL => Gil Vicente
MOR => Moreirense
POR => FC Porto
PTM => Portimonense
RIO => Rio Ave
SR => Sporting CP
VGU => Vitória de Guimarães
VSC => Vitória de Setúbal
Soccer - Other Teams
austin => Austin FC
cf_montral => CF Montréal

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 490 B

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 B

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 612 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 B

View File

@@ -5,10 +5,10 @@
"start_time": "07:00",
"end_time": "23:00"
},
"timezone": "America/New_York",
"timezone": "America/Chicago",
"location": {
"city": "Tampa",
"state": "Florida",
"city": "Dallas",
"state": "Texas",
"country": "US"
},
"display": {
@@ -82,10 +82,10 @@
"update_interval": 1
},
"weather": {
"enabled": true,
"enabled": false,
"update_interval": 1800,
"units": "imperial",
"display_format": "{temp}\u00b0F\n{condition}"
"display_format": "{temp}°F\n{condition}"
},
"stocks": {
"enabled": false,
@@ -130,12 +130,11 @@
"duration_buffer": 0.1
},
"odds_ticker": {
"enabled": false,
"enabled": true,
"show_favorite_teams_only": true,
"games_per_favorite_team": 1,
"max_games_per_league": 5,
"show_odds_only": false,
"fetch_odds": true,
"sort_order": "soonest",
"enabled_leagues": [
"nfl",
@@ -152,7 +151,7 @@
"dynamic_duration": true,
"min_duration": 30,
"max_duration": 300,
"duration_buffer": 0.05
"duration_buffer": 0.1
},
"leaderboard": {
"enabled": false,
@@ -269,6 +268,8 @@
"live_update_interval": 30,
"live_odds_update_interval": 3600,
"odds_update_interval": 3600,
"recent_update_interval": 3600,
"upcoming_update_interval": 3600,
"recent_games_to_show": 1,
"upcoming_games_to_show": 1,
"show_favorite_teams_only": true,
@@ -304,10 +305,9 @@
],
"logo_dir": "assets/sports/ncaa_logos",
"show_records": true,
"show_ranking": true,
"display_modes": {
"ncaa_fb_live": true,
"ncaa_fb_recent": true ,
"ncaa_fb_recent": true,
"ncaa_fb_upcoming": true
}
},
@@ -440,7 +440,7 @@
}
},
"text_display": {
"enabled": true,
"enabled": false,
"text": "Subscribe to ChuckBuilds",
"font_path": "assets/fonts/press-start-2p.ttf",
"font_size": 8,

View File

@@ -150,17 +150,18 @@ echo ""
echo "This script will perform the following steps:"
echo "1. Install system dependencies"
echo "2. Fix cache permissions"
echo "3. Install main LED Matrix service"
echo "4. Install Python project dependencies (requirements.txt)"
echo "5. Build and install rpi-rgb-led-matrix and test import"
echo "6. Install web interface dependencies"
echo "7. Install web interface service"
echo "8. Configure web interface permissions"
echo "9. Configure passwordless sudo access"
echo "10. Set up proper file ownership"
echo "11. Configure sound module to avoid conflicts"
echo "12. Apply performance optimizations"
echo "13. Test the installation"
echo "3. Fix assets directory permissions"
echo "4. Install main LED Matrix service"
echo "5. Install Python project dependencies (requirements.txt)"
echo "6. Build and install rpi-rgb-led-matrix and test import"
echo "7. Install web interface dependencies"
echo "8. Install web interface service"
echo "9. Configure web interface permissions"
echo "10. Configure passwordless sudo access"
echo "11. Set up proper file ownership"
echo "12. Configure sound module to avoid conflicts"
echo "13. Apply performance optimizations"
echo "14. Test the installation"
echo ""
# Ask for confirmation
@@ -217,8 +218,57 @@ else
fi
echo ""
CURRENT_STEP="Fix assets directory permissions"
echo "Step 3: Fixing assets directory permissions..."
echo "--------------------------------------------"
# Run the assets permissions fix
if [ -f "$PROJECT_ROOT_DIR/fix_assets_permissions.sh" ]; then
echo "Running assets permissions fix..."
bash "$PROJECT_ROOT_DIR/fix_assets_permissions.sh"
echo "✓ Assets permissions fixed"
else
echo "⚠ Assets permissions script not found, fixing permissions manually..."
# Set ownership of the entire assets directory to the real user
echo "Setting ownership of assets directory..."
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$PROJECT_ROOT_DIR/assets"
# Set permissions to allow read/write for owner and group, read for others
echo "Setting permissions for assets directory..."
chmod -R 775 "$PROJECT_ROOT_DIR/assets"
# Specifically ensure the sports logos directories are writable
SPORTS_DIRS=(
"sports/ncaa_fbs_logos"
"sports/nfl_logos"
"sports/nba_logos"
"sports/nhl_logos"
"sports/mlb_logos"
"sports/milb_logos"
"sports/soccer_logos"
)
echo "Ensuring sports logo directories are writable..."
for SPORTS_DIR in "${SPORTS_DIRS[@]}"; do
FULL_PATH="$PROJECT_ROOT_DIR/assets/$SPORTS_DIR"
if [ -d "$FULL_PATH" ]; then
chmod 775 "$FULL_PATH"
chown "$ACTUAL_USER:$ACTUAL_USER" "$FULL_PATH"
else
echo "Creating directory: $FULL_PATH"
mkdir -p "$FULL_PATH"
chown "$ACTUAL_USER:$ACTUAL_USER" "$FULL_PATH"
chmod 775 "$FULL_PATH"
fi
done
echo "✓ Assets permissions fixed manually"
fi
echo ""
CURRENT_STEP="Install main LED Matrix service"
echo "Step 3: Installing main LED Matrix service..."
echo "Step 4: Installing main LED Matrix service..."
echo "---------------------------------------------"
# Run the main service installation (idempotent)
@@ -233,14 +283,52 @@ else
fi
echo ""
CURRENT_STEP="Ensure secrets configuration exists"
echo "Step 3.1: Ensuring secrets configuration exists..."
echo "-----------------------------------------------"
CURRENT_STEP="Ensure configuration files exist"
echo "Step 4.1: Ensuring configuration files exist..."
echo "------------------------------------------------"
# Ensure config directory exists
mkdir -p "$PROJECT_ROOT_DIR/config"
chmod 755 "$PROJECT_ROOT_DIR/config" || true
# Create config.json from template if missing
if [ ! -f "$PROJECT_ROOT_DIR/config/config.json" ]; then
if [ -f "$PROJECT_ROOT_DIR/config/config.template.json" ]; then
echo "Creating config/config.json from template..."
cp "$PROJECT_ROOT_DIR/config/config.template.json" "$PROJECT_ROOT_DIR/config/config.json"
chown "$ACTUAL_USER:$ACTUAL_USER" "$PROJECT_ROOT_DIR/config/config.json" || true
chmod 644 "$PROJECT_ROOT_DIR/config/config.json"
echo "✓ Main config file created from template"
else
echo "⚠ Template config/config.template.json not found; creating a minimal config file"
cat > "$PROJECT_ROOT_DIR/config/config.json" <<'EOF'
{
"web_display_autostart": true,
"timezone": "America/Chicago",
"display": {
"hardware": {
"rows": 32,
"cols": 64,
"chain_length": 2,
"parallel": 1,
"brightness": 95,
"hardware_mapping": "adafruit-hat-pwm"
}
},
"clock": {
"enabled": true,
"format": "%I:%M %p"
}
}
EOF
chown "$ACTUAL_USER:$ACTUAL_USER" "$PROJECT_ROOT_DIR/config/config.json" || true
chmod 644 "$PROJECT_ROOT_DIR/config/config.json"
echo "✓ Minimal config file created"
fi
else
echo "✓ Main config file already exists"
fi
# Create config_secrets.json from template if missing
if [ ! -f "$PROJECT_ROOT_DIR/config/config_secrets.json" ]; then
if [ -f "$PROJECT_ROOT_DIR/config/config_secrets.template.json" ]; then
@@ -263,12 +351,12 @@ EOF
echo "✓ Minimal secrets file created"
fi
else
echo "Secrets file already exists; leaving as-is"
echo "Secrets file already exists"
fi
echo ""
CURRENT_STEP="Install project Python dependencies"
echo "Step 4: Installing Python project dependencies..."
echo "Step 5: Installing Python project dependencies..."
echo "-----------------------------------------------"
# Install main project Python dependencies
@@ -283,7 +371,7 @@ echo "✓ Project Python dependencies installed"
echo ""
CURRENT_STEP="Build and install rpi-rgb-led-matrix"
echo "Step 5: Building and installing rpi-rgb-led-matrix..."
echo "Step 6: Building and installing rpi-rgb-led-matrix..."
echo "-----------------------------------------------------"
# If already installed and not forcing rebuild, skip expensive build
@@ -327,7 +415,7 @@ fi
echo ""
CURRENT_STEP="Install web interface dependencies"
echo "Step 6: Installing web interface dependencies..."
echo "Step 7: Installing web interface dependencies..."
echo "------------------------------------------------"
# Install web interface dependencies
@@ -335,9 +423,9 @@ echo "Installing Python dependencies for web interface..."
cd "$PROJECT_ROOT_DIR"
# Try to install dependencies using the smart installer if available
if [ -f "$PROJECT_ROOT_DIR/install_dependencies_apt.py" ]; then
if [ -f "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py" ]; then
echo "Using smart dependency installer..."
python3 "$PROJECT_ROOT_DIR/install_dependencies_apt.py"
python3 "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
else
echo "Using pip to install dependencies..."
if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then
@@ -351,7 +439,7 @@ echo "✓ Web interface dependencies installed"
echo ""
CURRENT_STEP="Install web interface service"
echo "Step 7: Installing web interface service..."
echo "Step 8: Installing web interface service..."
echo "-------------------------------------------"
if [ -f "$PROJECT_ROOT_DIR/install_web_service.sh" ]; then
@@ -369,7 +457,7 @@ fi
echo ""
CURRENT_STEP="Harden systemd unit file permissions"
echo "Step 7.1: Setting systemd unit file permissions..."
echo "Step 8.1: Setting systemd unit file permissions..."
echo "-----------------------------------------------"
for unit in "/etc/systemd/system/ledmatrix.service" "/etc/systemd/system/ledmatrix-web.service"; do
if [ -f "$unit" ]; then
@@ -382,7 +470,7 @@ echo "✓ Systemd unit file permissions set"
echo ""
CURRENT_STEP="Configure web interface permissions"
echo "Step 8: Configuring web interface permissions..."
echo "Step 9: Configuring web interface permissions..."
echo "------------------------------------------------"
# Add user to required groups (idempotent)
@@ -404,7 +492,7 @@ echo "✓ User added to required groups"
echo ""
CURRENT_STEP="Configure passwordless sudo access"
echo "Step 9: Configuring passwordless sudo access..."
echo "Step 10: Configuring passwordless sudo access..."
echo "------------------------------------------------"
# Create sudoers configuration for the web interface
@@ -451,7 +539,7 @@ echo "✓ Passwordless sudo access configured"
echo ""
CURRENT_STEP="Set proper file ownership"
echo "Step 10: Setting proper file ownership..."
echo "Step 11: Setting proper file ownership..."
echo "----------------------------------------"
# Set ownership of project files to the user
@@ -471,11 +559,18 @@ if [ -f "$PROJECT_ROOT_DIR/config/config_secrets.json" ]; then
echo "✓ Secrets file permissions set"
fi
# Set proper permissions for YTM auth file (readable by all users including root service)
if [ -f "$PROJECT_ROOT_DIR/config/ytm_auth.json" ]; then
chown "$ACTUAL_USER:$ACTUAL_USER" "$PROJECT_ROOT_DIR/config/ytm_auth.json" || true
chmod 644 "$PROJECT_ROOT_DIR/config/ytm_auth.json"
echo "✓ YTM auth file permissions set"
fi
echo "✓ File ownership configured"
echo ""
CURRENT_STEP="Normalize project file permissions"
echo "Step 10.1: Normalizing project file and directory permissions..."
echo "Step 11.1: Normalizing project file and directory permissions..."
echo "--------------------------------------------------------------"
# Normalize directory permissions (exclude VCS metadata)
@@ -489,14 +584,14 @@ find "$PROJECT_ROOT_DIR" -path "*/.git*" -prune -o -type f -name "*.sh" -exec ch
# Explicitly ensure common helper scripts are executable (in case paths change)
chmod 755 "$PROJECT_ROOT_DIR/start_display.sh" "$PROJECT_ROOT_DIR/stop_display.sh" 2>/dev/null || true
chmod 755 "$PROJECT_ROOT_DIR/fix_cache_permissions.sh" "$PROJECT_ROOT_DIR/fix_web_permissions.sh" 2>/dev/null || true
chmod 755 "$PROJECT_ROOT_DIR/fix_cache_permissions.sh" "$PROJECT_ROOT_DIR/fix_web_permissions.sh" "$PROJECT_ROOT_DIR/fix_assets_permissions.sh" 2>/dev/null || true
chmod 755 "$PROJECT_ROOT_DIR/install_service.sh" "$PROJECT_ROOT_DIR/install_web_service.sh" 2>/dev/null || true
echo "✓ Project file permissions normalized"
echo ""
CURRENT_STEP="Sound module configuration"
echo "Step 11: Sound module configuration..."
echo "Step 12: Sound module configuration..."
echo "-------------------------------------"
# Remove services that may interfere with LED matrix timing
@@ -539,7 +634,7 @@ echo "✓ Sound module configuration applied"
echo ""
CURRENT_STEP="Apply performance optimizations"
echo "Step 12: Applying performance optimizations..."
echo "Step 13: Applying performance optimizations..."
echo "---------------------------------------------"
# Prefer /boot/firmware on newer Raspberry Pi OS, fall back to /boot on older
@@ -588,7 +683,7 @@ echo "✓ Performance optimizations applied"
echo ""
CURRENT_STEP="Test the installation"
echo "Step 13: Testing the installation..."
echo "Step 14: Testing the installation..."
echo "----------------------------------"
# Test sudo access
@@ -665,7 +760,8 @@ echo "Enable/disable web interface autostart:"
echo " Edit config/config.json and set 'web_display_autostart': true"
echo ""
echo "Configuration files:"
echo " Main config: config/config.json"
echo " Secrets: config/config_secrets.json (create from template if needed)"
echo " Main config: config/config.json (created from template automatically)"
echo " Secrets: config/config_secrets.json (created from template automatically)"
echo " Template: config/config.template.json (reference for new options)"
echo ""
echo "Enjoy your LED Matrix display!"

127
fix_assets_permissions.sh Normal file
View File

@@ -0,0 +1,127 @@
#!/bin/bash
# LEDMatrix Assets Permissions Fix Script
# This script fixes permissions on the assets directory so the application can download and save team logos
echo "Fixing LEDMatrix assets directory permissions..."
# Get the real user (not root when running with sudo)
REAL_USER=${SUDO_USER:-$USER}
# Resolve the home directory of the real user robustly
if command -v getent >/dev/null 2>&1; then
REAL_HOME=$(getent passwd "$REAL_USER" | cut -d: -f6)
else
REAL_HOME=$(eval echo ~"$REAL_USER")
fi
REAL_GROUP=$(id -gn "$REAL_USER")
# Get the project directory
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ASSETS_DIR="$PROJECT_DIR/assets"
echo "Project directory: $PROJECT_DIR"
echo "Assets directory: $ASSETS_DIR"
echo "Real user: $REAL_USER"
echo "Real group: $REAL_GROUP"
# Check if assets directory exists
if [ ! -d "$ASSETS_DIR" ]; then
echo "Error: Assets directory does not exist at $ASSETS_DIR"
exit 1
fi
echo ""
echo "Fixing permissions for assets directory and subdirectories..."
# Set ownership of the entire assets directory to the real user
echo "Setting ownership of assets directory..."
if sudo chown -R "$REAL_USER:$REAL_GROUP" "$ASSETS_DIR"; then
echo "✓ Set assets directory ownership to $REAL_USER:$REAL_GROUP"
else
echo "✗ Failed to set assets directory ownership"
exit 1
fi
# Set permissions to allow read/write for owner and group, read for others
echo "Setting permissions for assets directory..."
if sudo chmod -R 775 "$ASSETS_DIR"; then
echo "✓ Set assets directory permissions to 775"
else
echo "✗ Failed to set assets directory permissions"
exit 1
fi
# Specifically ensure the sports logos directories are writable
SPORTS_DIRS=(
"sports/ncaa_fbs_logos"
"sports/nfl_logos"
"sports/nba_logos"
"sports/nhl_logos"
"sports/mlb_logos"
"sports/milb_logos"
"sports/soccer_logos"
)
echo ""
echo "Ensuring sports logo directories are writable..."
for SPORTS_DIR in "${SPORTS_DIRS[@]}"; do
FULL_PATH="$ASSETS_DIR/$SPORTS_DIR"
echo ""
echo "Checking directory: $FULL_PATH"
if [ -d "$FULL_PATH" ]; then
echo " - Directory exists"
echo " - Current permissions:"
ls -ld "$FULL_PATH"
# Ensure the directory is writable
sudo chmod 775 "$FULL_PATH"
sudo chown "$REAL_USER:$REAL_GROUP" "$FULL_PATH"
echo " - Updated permissions:"
ls -ld "$FULL_PATH"
# Test write access
echo " - Testing write access as $REAL_USER..."
if sudo -u "$REAL_USER" test -w "$FULL_PATH"; then
echo "$FULL_PATH is writable by $REAL_USER"
else
echo "$FULL_PATH is not writable by $REAL_USER"
fi
else
echo " - Directory does not exist, creating it..."
sudo mkdir -p "$FULL_PATH"
sudo chown "$REAL_USER:$REAL_GROUP" "$FULL_PATH"
sudo chmod 775 "$FULL_PATH"
echo " - Created directory with proper permissions"
fi
done
echo ""
echo "Testing write access to ncaa_fbs_logos directory specifically..."
NCAA_DIR="$ASSETS_DIR/sports/ncaa_fbs_logos"
if [ -d "$NCAA_DIR" ]; then
# Create a test file to verify write access
TEST_FILE="$NCAA_DIR/.permission_test"
if sudo -u "$REAL_USER" touch "$TEST_FILE" 2>/dev/null; then
echo "✓ Successfully created test file in ncaa_fbs_logos directory"
sudo -u "$REAL_USER" rm -f "$TEST_FILE"
echo "✓ Successfully removed test file"
else
echo "✗ Failed to create test file in ncaa_fbs_logos directory"
echo " This indicates the permission fix did not work properly"
fi
else
echo "✗ ncaa_fbs_logos directory does not exist"
fi
echo ""
echo "Assets permissions fix completed!"
echo ""
echo "The application should now be able to download and save team logos."
echo "If you still see permission errors, check which user is running the LEDMatrix service"
echo "and ensure it matches the owner above ($REAL_USER)."
echo ""
echo "You may need to restart the LEDMatrix service for the changes to take effect:"
echo " sudo systemctl restart ledmatrix.service"

43
migrate_config.sh Normal file
View File

@@ -0,0 +1,43 @@
#!/bin/bash
# LED Matrix Configuration Migration Script
# This script helps migrate existing config.json to the new template-based system
set -e
echo "=========================================="
echo "LED Matrix Configuration Migration Script"
echo "=========================================="
echo ""
# Check if we're in the right directory
if [ ! -f "config/config.template.json" ]; then
echo "Error: config/config.template.json not found."
echo "Please run this script from the LEDMatrix project root directory."
exit 1
fi
# Check if config.json exists
if [ ! -f "config/config.json" ]; then
echo "No existing config.json found. Creating from template..."
cp config/config.template.json config/config.json
echo "✓ Created config/config.json from template"
echo ""
echo "You can now edit config/config.json with your preferences."
exit 0
fi
echo "Existing config.json found. The system will automatically handle migration."
echo ""
echo "What this means:"
echo "- Your current config.json will be preserved"
echo "- New configuration options will be automatically added with default values"
echo "- A backup will be created before any changes"
echo "- The system handles this automatically when it starts"
echo ""
echo "No manual migration is needed. The ConfigManager will handle everything automatically."
echo ""
echo "To see the latest configuration options, you can reference:"
echo " config/config.template.json"
echo ""
echo "Migration complete!"

File diff suppressed because it is too large Load Diff

View File

@@ -101,7 +101,7 @@ def show_status():
def main():
if len(sys.argv) < 2:
print("Usage: python3 enable_news_manager.py [enable|disable|status]")
print("Usage: python3 scripts/enable_news_manager.py [enable|disable|status]")
sys.exit(1)
command = sys.argv[1].lower()

View File

@@ -7,6 +7,7 @@ class ConfigManager:
# Use current working directory as base
self.config_path = config_path or "config/config.json"
self.secrets_path = secrets_path or "config/config_secrets.json"
self.template_path = "config/config.template.json"
self.config: Dict[str, Any] = {}
def get_config_path(self) -> str:
@@ -18,11 +19,18 @@ class ConfigManager:
def load_config(self) -> Dict[str, Any]:
"""Load configuration from JSON files."""
try:
# Check if config file exists, if not create from template
if not os.path.exists(self.config_path):
self._create_config_from_template()
# Load main config
print(f"Attempting to load config from: {os.path.abspath(self.config_path)}")
with open(self.config_path, 'r') as f:
self.config = json.load(f)
# Migrate config to add any new items from template
self._migrate_config()
# Load and merge secrets if they exist (be permissive on errors)
if os.path.exists(self.secrets_path):
try:
@@ -118,6 +126,85 @@ class ConfigManager:
else:
target[key] = value
def _create_config_from_template(self) -> None:
"""Create config.json from template if it doesn't exist."""
if not os.path.exists(self.template_path):
raise FileNotFoundError(f"Template file not found at {os.path.abspath(self.template_path)}")
print(f"Creating config.json from template at {os.path.abspath(self.template_path)}")
# Ensure config directory exists
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
# Copy template to config
with open(self.template_path, 'r') as template_file:
template_data = json.load(template_file)
with open(self.config_path, 'w') as config_file:
json.dump(template_data, config_file, indent=4)
print(f"Created config.json from template at {os.path.abspath(self.config_path)}")
def _migrate_config(self) -> None:
"""Migrate config to add new items from template with defaults."""
if not os.path.exists(self.template_path):
print(f"Template file not found at {os.path.abspath(self.template_path)}, skipping migration")
return
try:
with open(self.template_path, 'r') as f:
template_config = json.load(f)
# Check if migration is needed
if self._config_needs_migration(self.config, template_config):
print("Config migration needed - adding new configuration items with defaults")
# Create backup of current config
backup_path = f"{self.config_path}.backup"
with open(backup_path, 'w') as backup_file:
json.dump(self.config, backup_file, indent=4)
print(f"Created backup of current config at {os.path.abspath(backup_path)}")
# Merge template defaults into current config
self._merge_template_defaults(self.config, template_config)
# Save migrated config
with open(self.config_path, 'w') as f:
json.dump(self.config, f, indent=4)
print(f"Config migration completed and saved to {os.path.abspath(self.config_path)}")
else:
print("Config is up to date, no migration needed")
except Exception as e:
print(f"Error during config migration: {e}")
# Don't raise - continue with current config
def _config_needs_migration(self, current_config: Dict[str, Any], template_config: Dict[str, Any]) -> bool:
"""Check if config needs migration by comparing with template."""
return self._has_new_keys(current_config, template_config)
def _has_new_keys(self, current: Dict[str, Any], template: Dict[str, Any]) -> bool:
"""Recursively check if template has keys not in current config."""
for key, value in template.items():
if key not in current:
return True
if isinstance(value, dict) and isinstance(current[key], dict):
if self._has_new_keys(current[key], value):
return True
return False
def _merge_template_defaults(self, current: Dict[str, Any], template: Dict[str, Any]) -> None:
"""Recursively merge template defaults into current config."""
for key, value in template.items():
if key not in current:
# Add new key with template value
current[key] = value
print(f"Added new config key: {key}")
elif isinstance(value, dict) and isinstance(current[key], dict):
# Recursively merge nested dictionaries
self._merge_template_defaults(current[key], value)
def get_timezone(self) -> str:
"""Get the configured timezone."""
return self.config.get('timezone', 'UTC')

View File

@@ -578,6 +578,11 @@ class DisplayController:
self.display_manager.defer_update(self.stocks.update_stock_data, priority=2)
if self.news:
self.display_manager.defer_update(self.news.update_news_data, priority=2)
# Defer sport manager updates that might do heavy API fetching
if hasattr(self, 'ncaa_fb_live') and self.ncaa_fb_live:
self.display_manager.defer_update(self.ncaa_fb_live.update, priority=3)
if hasattr(self, 'nfl_live') and self.nfl_live:
self.display_manager.defer_update(self.nfl_live.update, priority=3)
# Continue with non-scrolling-sensitive updates
if self.weather: self.weather.get_weather()
if self.calendar: self.calendar.update(time.time())

View File

@@ -32,7 +32,17 @@ class LogoDownloader:
'fcs': 'https://site.api.espn.com/apis/site/v2/sports/football/college-football/teams', # FCS teams from same endpoint
'ncaam_basketball': 'https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/teams',
'ncaa_baseball': 'https://site.api.espn.com/apis/site/v2/sports/baseball/college-baseball/teams',
"ncaam_hockey": "https://site.api.espn.com/apis/site/v2/sports/hockey/mens-college-hockey/teams"
"ncaam_hockey": "https://site.api.espn.com/apis/site/v2/sports/hockey/mens-college-hockey/teams",
# Soccer leagues
'soccer_eng.1': 'https://site.api.espn.com/apis/site/v2/sports/soccer/eng.1/teams',
'soccer_esp.1': 'https://site.api.espn.com/apis/site/v2/sports/soccer/esp.1/teams',
'soccer_ger.1': 'https://site.api.espn.com/apis/site/v2/sports/soccer/ger.1/teams',
'soccer_ita.1': 'https://site.api.espn.com/apis/site/v2/sports/soccer/ita.1/teams',
'soccer_fra.1': 'https://site.api.espn.com/apis/site/v2/sports/soccer/fra.1/teams',
'soccer_por.1': 'https://site.api.espn.com/apis/site/v2/sports/soccer/por.1/teams',
'soccer_uefa.champions': 'https://site.api.espn.com/apis/site/v2/sports/soccer/uefa.champions/teams',
'soccer_uefa.europa': 'https://site.api.espn.com/apis/site/v2/sports/soccer/uefa.europa/teams',
'soccer_usa.1': 'https://site.api.espn.com/apis/site/v2/sports/soccer/usa.1/teams'
}
# Directory mappings for different leagues
@@ -47,6 +57,16 @@ class LogoDownloader:
'ncaam_basketball': 'assets/sports/ncaa_logos',
'ncaa_baseball': 'assets/sports/ncaa_logos',
'ncaam_hockey': 'assets/sports/ncaa_logos',
# Soccer leagues - all use the same soccer_logos directory
'soccer_eng.1': 'assets/sports/soccer_logos',
'soccer_esp.1': 'assets/sports/soccer_logos',
'soccer_ger.1': 'assets/sports/soccer_logos',
'soccer_ita.1': 'assets/sports/soccer_logos',
'soccer_fra.1': 'assets/sports/soccer_logos',
'soccer_por.1': 'assets/sports/soccer_logos',
'soccer_uefa.champions': 'assets/sports/soccer_logos',
'soccer_uefa.europa': 'assets/sports/soccer_logos',
'soccer_usa.1': 'assets/sports/soccer_logos'
}
def __init__(self, request_timeout: int = 30, retry_attempts: int = 3):
@@ -607,6 +627,20 @@ class LogoDownloader:
return converted_count, failed_count
# Helper function to map soccer league codes to logo downloader format
def get_soccer_league_key(league_code: str) -> str:
"""
Map soccer league codes to logo downloader format.
Args:
league_code: Soccer league code (e.g., 'eng.1', 'por.1')
Returns:
Logo downloader league key (e.g., 'soccer_eng.1', 'soccer_por.1')
"""
return f"soccer_{league_code}"
# Convenience function for easy integration
def download_missing_logo(team_abbreviation: str, league: str, team_name: str = None, create_placeholder: bool = True) -> bool:
"""

View File

@@ -1502,8 +1502,27 @@ class MiLBRecentManager(BaseMiLBManager):
logger.info(f"[MiLB] All games found ({len(all_games_log)}): {all_games_log}")
logger.info(f"[MiLB] Favorite team games found ({len(favorite_games_log)}): {favorite_games_log}")
# Sort by game time (most recent first) and limit to recent_games_to_show
# Sort by game time (most recent first) and apply per-team logic
new_recent_games.sort(key=lambda x: x.get('start_time', ''), reverse=True)
# If showing favorite teams only, select one game per team
if self.milb_config.get("show_favorite_teams_only", False):
# Select one game per favorite team (most recent game for each team)
team_games = []
for team in self.favorite_teams:
# Find games where this team is playing
team_specific_games = [game for game in new_recent_games
if game.get('home_team') == team or game.get('away_team') == team]
if team_specific_games:
# Take the most recent (first in sorted list)
team_games.append(team_specific_games[0])
# Sort the final list by game time (most recent first)
team_games.sort(key=lambda x: x.get('start_time', ''), reverse=True)
new_recent_games = team_games
else:
# Limit to configured number if not using favorite teams only
new_recent_games = new_recent_games[:self.recent_games_to_show]
if new_recent_games:
@@ -1716,8 +1735,27 @@ class MiLBUpcomingManager(BaseMiLBManager):
self.logger.info(f"[MiLB] Added upcoming game: {game.get('away_team')} @ {game.get('home_team')} at {game_time}")
self.logger.debug(f"[MiLB] Game data for upcoming: {game}")
# Sort by game time (soonest first) and limit to upcoming_games_to_show
# Sort by game time (soonest first) and apply per-team logic
new_upcoming_games.sort(key=lambda x: x.get('start_time', ''))
# If showing favorite teams only, select one game per team
if self.milb_config.get("show_favorite_teams_only", False):
# Select one game per favorite team (earliest upcoming game for each team)
team_games = []
for team in self.favorite_teams:
# Find games where this team is playing
team_specific_games = [game for game in new_upcoming_games
if game.get('home_team') == team or game.get('away_team') == team]
if team_specific_games:
# Take the earliest (first in sorted list)
team_games.append(team_specific_games[0])
# Sort the final list by game time
team_games.sort(key=lambda x: x.get('start_time', ''))
new_upcoming_games = team_games
else:
# Limit to configured number if not using favorite teams only
new_upcoming_games = new_upcoming_games[:self.upcoming_games_to_show]
self.logger.info(f"[MiLB] Found {len(new_upcoming_games)} upcoming games after processing")

View File

@@ -1223,8 +1223,27 @@ class MLBRecentManager(BaseMLBManager):
self.logger.info(f"[MLB] All games found ({len(all_games_log)}): {all_games_log}")
self.logger.info(f"[MLB] Favorite team games found ({len(favorite_games_log)}): {favorite_games_log}")
# Sort by game time (most recent first) and limit to recent_games_to_show
# Sort by game time (most recent first) and apply per-team logic
new_recent_games.sort(key=lambda x: x['start_time'], reverse=True)
# If showing favorite teams only, select one game per team
if self.mlb_config.get("show_favorite_teams_only", False):
# Select one game per favorite team (most recent game for each team)
team_games = []
for team in self.favorite_teams:
# Find games where this team is playing
team_specific_games = [game for game in new_recent_games
if game['home_team'] == team or game['away_team'] == team]
if team_specific_games:
# Take the most recent (first in sorted list)
team_games.append(team_specific_games[0])
# Sort the final list by game time (most recent first)
team_games.sort(key=lambda x: x['start_time'], reverse=True)
new_recent_games = team_games
else:
# Limit to configured number if not using favorite teams only
new_recent_games = new_recent_games[:self.recent_games_to_show]
if new_recent_games:
@@ -1345,8 +1364,27 @@ class MLBUpcomingManager(BaseMLBManager):
else:
self.logger.info(f"[MLB] Skipping game {game_id} - not upcoming.")
# Sort by game time (soonest first) and limit to upcoming_games_to_show
# Sort by game time (soonest first) and apply per-team logic
new_upcoming_games.sort(key=lambda x: x['start_time'])
# If showing favorite teams only, select one game per team
if self.mlb_config.get("show_favorite_teams_only", False):
# Select one game per favorite team (earliest upcoming game for each team)
team_games = []
for team in self.favorite_teams:
# Find games where this team is playing
team_specific_games = [game for game in new_upcoming_games
if game['home_team'] == team or game['away_team'] == team]
if team_specific_games:
# Take the earliest (first in sorted list)
team_games.append(team_specific_games[0])
# Sort the final list by game time
team_games.sort(key=lambda x: x['start_time'])
new_upcoming_games = team_games
else:
# Limit to configured number if not using favorite teams only
new_upcoming_games = new_upcoming_games[:self.upcoming_games_to_show]
if new_upcoming_games:

View File

@@ -27,10 +27,7 @@ except ImportError:
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Define paths relative to this file's location
CONFIG_DIR = os.path.join(os.path.dirname(__file__), '..', 'config')
CONFIG_PATH = os.path.join(CONFIG_DIR, 'config.json')
# SECRETS_PATH is handled within SpotifyClient
# Note: Config is now passed in from DisplayController instead of being loaded separately
class MusicSource(Enum):
NONE = auto()
@@ -72,17 +69,15 @@ class MusicManager:
def _load_config(self):
default_interval = 2
# default_preferred_source = "auto" # Removed
self.enabled = False # Assume disabled until config proves otherwise
if not os.path.exists(CONFIG_PATH):
logging.warning(f"Config file not found at {CONFIG_PATH}. Music manager disabled.")
# Use the config that was already loaded and passed to us instead of loading our own
if self.config is None:
logging.warning("No config provided to MusicManager. Music manager disabled.")
return
try:
with open(CONFIG_PATH, 'r') as f:
config_data = json.load(f)
music_config = config_data.get("music", {})
music_config = self.config.get("music", {})
self.enabled = music_config.get("enabled", False)
if not self.enabled:
@@ -100,9 +95,6 @@ class MusicManager:
self.enabled = False
return
except json.JSONDecodeError:
logging.error(f"Error decoding JSON from {CONFIG_PATH}. Music manager disabled.")
self.enabled = False
except Exception as e:
logging.error(f"Error loading music config: {e}. Music manager disabled.")
self.enabled = False
@@ -777,10 +769,22 @@ class MusicManager:
ARTIST_Y_PERCENT = 0.34 # 34% from top
ALBUM_Y_PERCENT = 0.60 # 60% from top
# Calculate actual pixel positions
# Get font height for artist/album text
try:
artist_album_font_height = self.display_manager.get_font_height(font_artist_album)
except:
artist_album_font_height = LINE_HEIGHT_BDF # Fallback to BDF height
# Ensure we have a reasonable shift (minimum 6 pixels)
font_shift = max(artist_album_font_height, 6)
# Calculate actual pixel positions, shifted down by font height
y_pos_title_top = 1
y_pos_artist_top = int(matrix_height * ARTIST_Y_PERCENT)
y_pos_album_top = int(matrix_height * ALBUM_Y_PERCENT)
y_pos_artist_top = int(matrix_height * ARTIST_Y_PERCENT) + font_shift
# For album, use a smaller shift to ensure it fits above progress bar
album_shift = min(font_shift, 5) # Cap album shift at 5 pixels to preserve space
y_pos_album_top = int(matrix_height * ALBUM_Y_PERCENT) + album_shift
TEXT_SCROLL_DIVISOR = 5
@@ -916,15 +920,12 @@ if __name__ == '__main__':
# The MusicManager expects the overall config, not just the music part directly for its _load_config
# So we simulate a config object that has a .get('music', {}) method.
# However, MusicManager's _load_config reads from CONFIG_PATH.
# For a true standalone test, we might need to mock file IO or provide a test config file.
# MusicManager now uses the passed config instead of loading from file.
# Simplified test:
# manager = MusicManager(display_manager=mock_display, config=mock_config_main) # This won't work due to file reading
manager = MusicManager(display_manager=mock_display, config=mock_config_main)
# To truly test, you'd point CONFIG_PATH to a test config.json or mock open()
# For now, this __main__ block is mostly a placeholder.
logger.info("MusicManager standalone test setup is complex due to file dependencies for config.")
logger.info("MusicManager standalone test setup completed.")
logger.info("To test: run the main application and observe logs from MusicManager.")
# if manager.enabled:
# manager.start_polling()

View File

@@ -759,12 +759,27 @@ class NBARecentManager(BaseNBAManager):
# Filter for favorite teams only if the config is set
if self.nba_config.get("show_favorite_teams_only", False):
team_games = [game for game in new_recent_games
# Get all games involving favorite teams
favorite_team_games = [game for game in new_recent_games
if game['home_abbr'] in self.favorite_teams or
game['away_abbr'] in self.favorite_teams]
# Select one game per favorite team (most recent game for each team)
team_games = []
for team in self.favorite_teams:
# Find games where this team is playing
team_specific_games = [game for game in favorite_team_games
if game['home_abbr'] == team or game['away_abbr'] == team]
if team_specific_games:
# Sort by game time and take the most recent
team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
team_games.append(team_specific_games[0])
# Sort the final list by game time (most recent first)
team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
else:
team_games = new_recent_games
# Sort games by start time, most recent first, then limit to recent_games_to_show
team_games.sort(key=lambda x: x.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
team_games = team_games[:self.recent_games_to_show]
@@ -838,12 +853,27 @@ class NBAUpcomingManager(BaseNBAManager):
# Filter for favorite teams only if the config is set
if self.nba_config.get("show_favorite_teams_only", False):
team_games = [game for game in new_upcoming_games
# Get all games involving favorite teams
favorite_team_games = [game for game in new_upcoming_games
if game['home_abbr'] in self.favorite_teams or
game['away_abbr'] in self.favorite_teams]
# Select one game per favorite team (earliest upcoming game for each team)
team_games = []
for team in self.favorite_teams:
# Find games where this team is playing
team_specific_games = [game for game in favorite_team_games
if game['home_abbr'] == team or game['away_abbr'] == team]
if team_specific_games:
# Sort by game time and take the earliest
team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc))
team_games.append(team_specific_games[0])
# Sort the final list by game time
team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc))
else:
team_games = new_upcoming_games
# Sort games by start time, soonest first, then limit to configured count
team_games.sort(key=lambda x: x.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc))
team_games = team_games[:self.upcoming_games_to_show]

View File

@@ -908,14 +908,32 @@ class NCAABaseballRecentManager(BaseNCAABaseballManager):
# Filter for favorite teams only if the config is set
if self.ncaa_baseball_config.get("show_favorite_teams_only", False):
team_games = [game for game in new_recent_games if game['home_team'] in self.favorite_teams or game['away_team'] in self.favorite_teams]
# Get all games involving favorite teams
favorite_team_games = [game for game in new_recent_games
if game['home_team'] in self.favorite_teams or
game['away_team'] in self.favorite_teams]
# Select one game per favorite team (most recent game for each team)
team_games = []
for team in self.favorite_teams:
# Find games where this team is playing
team_specific_games = [game for game in favorite_team_games
if game['home_team'] == team or game['away_team'] == team]
if team_specific_games:
# Sort by game time and take the most recent
team_specific_games.sort(key=lambda g: g.get('start_time'), reverse=True)
team_games.append(team_specific_games[0])
# Sort the final list by game time (most recent first)
team_games.sort(key=lambda g: g.get('start_time'), reverse=True)
else:
team_games = new_recent_games
if team_games:
# Sort by game time (most recent first), then limit to recent_games_to_show
team_games = sorted(team_games, key=lambda g: g.get('start_time'), reverse=True)
team_games = team_games[:self.recent_games_to_show]
if team_games:
logger.info(f"[NCAABaseball] Found {len(team_games)} recent games for favorite teams (limited to {self.recent_games_to_show}): {self.favorite_teams}")
self.recent_games = team_games
if not self.current_game or self.current_game.get('id') not in [g.get('id') for g in self.recent_games]:
@@ -1010,14 +1028,32 @@ class NCAABaseballUpcomingManager(BaseNCAABaseballManager):
# Filter for favorite teams only if the config is set
if self.ncaa_baseball_config.get("show_favorite_teams_only", False):
team_games = [game for game in new_upcoming_games if game['home_team'] in self.favorite_teams or game['away_team'] in self.favorite_teams]
# Get all games involving favorite teams
favorite_team_games = [game for game in new_upcoming_games
if game['home_team'] in self.favorite_teams or
game['away_team'] in self.favorite_teams]
# Select one game per favorite team (earliest upcoming game for each team)
team_games = []
for team in self.favorite_teams:
# Find games where this team is playing
team_specific_games = [game for game in favorite_team_games
if game['home_team'] == team or game['away_team'] == team]
if team_specific_games:
# Sort by game time and take the earliest
team_specific_games.sort(key=lambda g: g.get('start_time'))
team_games.append(team_specific_games[0])
# Sort the final list by game time
team_games.sort(key=lambda g: g.get('start_time'))
else:
team_games = new_upcoming_games
if team_games:
# Sort by game time (soonest first), then limit to configured count
team_games = sorted(team_games, key=lambda g: g.get('start_time'))
team_games = team_games[:self.upcoming_games_to_show]
if team_games:
logger.info(f"[NCAABaseball] Found {len(team_games)} upcoming games for favorite teams (limited to {self.upcoming_games_to_show})")
self.upcoming_games = team_games
if not self.current_game or self.current_game.get('id') not in [g.get('id') for g in self.upcoming_games]:

View File

@@ -208,8 +208,10 @@ class BaseNCAAFBManager: # Renamed class
def _fetch_ncaa_fb_api_data(self, use_cache: bool = True) -> Optional[Dict]:
"""
Fetches the full season schedule for NCAAFB, caches it, and then filters
for relevant games based on the current configuration.
Fetches the full season schedule for NCAAFB using week-by-week approach to ensure
we get all games, then caches the complete dataset.
This method now uses background threading to prevent blocking the display.
"""
now = datetime.now(pytz.utc)
current_year = now.year
@@ -227,7 +229,25 @@ class BaseNCAAFBManager: # Renamed class
all_events.extend(cached_data)
continue
# Check if we're already fetching this year's data in background
if hasattr(self, '_background_fetching') and year in self._background_fetching:
self.logger.info(f"[NCAAFB] Background fetch already in progress for {year}, using partial data")
# Return partial data if available, or trigger background fetch
partial_data = self._get_partial_schedule_data(year)
if partial_data:
all_events.extend(partial_data)
continue
self.logger.info(f"[NCAAFB] Fetching full {year} season schedule from ESPN API...")
# Start background fetch for complete data
self._start_background_schedule_fetch(year)
# For immediate response, fetch current/recent games only
year_events = self._fetch_immediate_games(year)
all_events.extend(year_events)
# Also try to fetch full season data immediately as fallback
try:
url = f"https://site.api.espn.com/apis/site/v2/sports/football/college-football/scoreboard"
response = self.session.get(url, params={"dates": year,"seasontype":2,"limit":1000},headers=self.headers, timeout=15)
@@ -248,6 +268,115 @@ class BaseNCAAFBManager: # Renamed class
return {'events': all_events}
def _fetch_immediate_games(self, year: int) -> List[Dict]:
"""Fetch immediate games (current week + next few days) for quick display."""
immediate_events = []
try:
# Fetch current week and next few days for immediate display
now = datetime.now(pytz.utc)
for days_offset in range(-1, 7): # Yesterday through next 6 days
check_date = now + timedelta(days=days_offset)
date_str = check_date.strftime('%Y%m%d')
url = f"https://site.api.espn.com/apis/site/v2/sports/football/college-football/scoreboard?dates={date_str}"
response = self.session.get(url, headers=self.headers, timeout=10)
response.raise_for_status()
data = response.json()
date_events = data.get('events', [])
immediate_events.extend(date_events)
if days_offset == 0: # Today
self.logger.debug(f"[NCAAFB] Immediate fetch - Current date ({date_str}): {len(date_events)} events")
except requests.exceptions.RequestException as e:
self.logger.warning(f"[NCAAFB] Error fetching immediate games for {year}: {e}")
return immediate_events
def _start_background_schedule_fetch(self, year: int):
"""Start background thread to fetch complete season schedule."""
import threading
if not hasattr(self, '_background_fetching'):
self._background_fetching = set()
if year in self._background_fetching:
return # Already fetching
self._background_fetching.add(year)
def background_fetch():
try:
start_time = time.time()
self.logger.info(f"[NCAAFB] Starting background fetch for {year} season...")
year_events = []
# Fetch week by week to ensure we get complete season data
for week in range(1, 16):
# Add timeout check to prevent infinite background fetching
if time.time() - start_time > 300: # 5 minute timeout
self.logger.warning(f"[NCAAFB] Background fetch timeout after 5 minutes for {year}")
break
try:
url = f"https://site.api.espn.com/apis/site/v2/sports/football/college-football/scoreboard?seasontype=2&week={week}"
response = self.session.get(url, headers=self.headers, timeout=15)
response.raise_for_status()
data = response.json()
week_events = data.get('events', [])
year_events.extend(week_events)
# Log progress for first few weeks
if week <= 3:
self.logger.debug(f"[NCAAFB] Background - Week {week}: fetched {len(week_events)} events")
# If no events found for this week, we might be past the season
if not week_events and week > 10:
self.logger.debug(f"[NCAAFB] Background - No events found for week {week}, ending season fetch")
break
except requests.exceptions.RequestException as e:
self.logger.warning(f"[NCAAFB] Background - Error fetching week {week} for {year}: {e}")
continue
# Also fetch postseason games (bowl games, playoffs) if we haven't timed out
if time.time() - start_time < 300:
try:
url = f"https://site.api.espn.com/apis/site/v2/sports/football/college-football/scoreboard?seasontype=3"
response = self.session.get(url, headers=self.headers, timeout=15)
response.raise_for_status()
data = response.json()
postseason_events = data.get('events', [])
year_events.extend(postseason_events)
self.logger.debug(f"[NCAAFB] Background - Postseason: fetched {len(postseason_events)} events")
except requests.exceptions.RequestException as e:
self.logger.warning(f"[NCAAFB] Background - Error fetching postseason for {year}: {e}")
# Cache the complete data
cache_key = f"ncaafb_schedule_{year}"
self.cache_manager.set(cache_key, year_events)
elapsed_time = time.time() - start_time
self.logger.info(f"[NCAAFB] Background fetch completed for {year}: {len(year_events)} events cached in {elapsed_time:.1f}s")
except Exception as e:
self.logger.error(f"[NCAAFB] Background fetch failed for {year}: {e}")
finally:
self._background_fetching.discard(year)
# Start background thread
fetch_thread = threading.Thread(target=background_fetch, daemon=True)
fetch_thread.start()
def _get_partial_schedule_data(self, year: int) -> List[Dict]:
"""Get partial schedule data if available from cache or previous fetch."""
cache_key = f"ncaafb_schedule_{year}"
cached_data = self.cache_manager.get(cache_key, max_age=self.season_cache_duration * 2) # Allow older data
if cached_data:
self.logger.debug(f"[NCAAFB] Using partial cached data for {year}: {len(cached_data)} events")
return cached_data
return []
def _fetch_data(self, date_str: str = None) -> Optional[Dict]:
"""Fetch data using shared data mechanism or direct fetch for live."""
if isinstance(self, NCAAFBLiveManager):
@@ -469,7 +598,7 @@ class BaseNCAAFBManager: # Renamed class
# Only log debug info for favorite team games
if is_favorite_game:
self.logger.debug(f"[NCAAFB] Processing favorite team game: {game_event.get('id')}")
self.logger.debug(f"[NCAAFB] Found teams: {away_abbr}@{home_abbr}, Status: {status['type']['name']}")
self.logger.debug(f"[NCAAFB] Found teams: {away_abbr}@{home_abbr}, Status: {status['type']['name']}, State: {status['type']['state']}")
home_record = home_team.get('records', [{}])[0].get('summary', '') if home_team.get('records') else ''
away_record = away_team.get('records', [{}])[0].get('summary', '') if away_team.get('records') else ''
@@ -499,16 +628,38 @@ class BaseNCAAFBManager: # Renamed class
situation = competition.get("situation")
down_distance_text = ""
possession_indicator = None # Default to None
scoring_event = "" # Track scoring events
if situation and status["type"]["state"] == "in":
down = situation.get("down")
distance = situation.get("distance")
if down and distance is not None:
# Validate down and distance values before formatting
if (down is not None and isinstance(down, int) and 1 <= down <= 4 and
distance is not None and isinstance(distance, int) and distance >= 0):
down_str = {1: "1st", 2: "2nd", 3: "3rd", 4: "4th"}.get(down, f"{down}th")
dist_str = f"& {distance}" if distance > 0 else "& Goal"
down_distance_text = f"{down_str} {dist_str}"
elif situation.get("isRedZone"):
down_distance_text = "Red Zone" # Simplified if down/distance not present but in redzone
# Detect scoring events from status detail
status_detail = status["type"].get("detail", "").lower()
status_short = status["type"].get("shortDetail", "").lower()
# Check for scoring events in status text
if any(keyword in status_detail for keyword in ["touchdown", "td"]):
scoring_event = "TOUCHDOWN"
elif any(keyword in status_detail for keyword in ["field goal", "fg"]):
scoring_event = "FIELD GOAL"
elif any(keyword in status_detail for keyword in ["extra point", "pat", "point after"]):
scoring_event = "PAT"
elif any(keyword in status_short for keyword in ["touchdown", "td"]):
scoring_event = "TOUCHDOWN"
elif any(keyword in status_short for keyword in ["field goal", "fg"]):
scoring_event = "FIELD GOAL"
elif any(keyword in status_short for keyword in ["extra point", "pat"]):
scoring_event = "PAT"
# Determine possession based on team ID
possession_team_id = situation.get("possession")
if possession_team_id:
@@ -524,14 +675,13 @@ class BaseNCAAFBManager: # Renamed class
if period == 0: period_text = "Start" # Before kickoff
elif period == 1: period_text = "Q1"
elif period == 2: period_text = "Q2"
elif period == 3: period_text = "HALF" # Halftime is usually period 3 in API
elif period == 4: period_text = "Q3"
elif period == 5: period_text = "Q4"
elif period > 5: period_text = "OT" # Assuming OT starts at period 6+
elif period == 3: period_text = "Q3" # Fixed: period 3 is 3rd quarter, not halftime
elif period == 4: period_text = "Q4"
elif period > 4: period_text = "OT" # OT starts after Q4
elif status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME": # Check explicit halftime state
period_text = "HALF"
elif status["type"]["state"] == "post":
if period > 5 : period_text = "Final/OT"
if period > 4 : period_text = "Final/OT"
else: period_text = "Final"
elif status["type"]["state"] == "pre":
period_text = game_time # Show time for upcoming
@@ -555,7 +705,8 @@ class BaseNCAAFBManager: # Renamed class
"clock": status.get("displayClock", "0:00"),
"is_live": status["type"]["state"] == "in",
"is_final": status["type"]["state"] == "post",
"is_upcoming": status["type"]["state"] == "pre",
"is_upcoming": (status["type"]["state"] == "pre" or
status["type"]["name"].lower() in ['scheduled', 'pre-game', 'status_scheduled']),
"is_halftime": status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME", # Added halftime check
"home_abbr": home_abbr,
"home_score": home_team.get("score", "0"),
@@ -572,6 +723,7 @@ class BaseNCAAFBManager: # Renamed class
"down_distance_text": down_distance_text, # Added Down/Distance
"possession": situation.get("possession") if situation else None, # ID of team with possession
"possession_indicator": possession_indicator, # Added for easy home/away check
"scoring_event": scoring_event, # Track scoring events (TOUCHDOWN, FIELD GOAL, PAT)
"is_within_window": is_within_window, # Whether game is within display window
}
@@ -700,11 +852,13 @@ class NCAAFBLiveManager(BaseNCAAFBManager): # Renamed class
minutes -= 1
if minutes < 0:
# Simulate end of quarter/game
if self.current_game["period"] < 5: # Assuming 5 is Q4 end
if self.current_game["period"] < 4: # Q4 is period 4
self.current_game["period"] += 1
# Update period_text based on new period
if self.current_game["period"] == 3: self.current_game["period_text"] = "HALF"
elif self.current_game["period"] == 5: self.current_game["period_text"] = "Q4"
if self.current_game["period"] == 1: self.current_game["period_text"] = "Q1"
elif self.current_game["period"] == 2: self.current_game["period_text"] = "Q2"
elif self.current_game["period"] == 3: self.current_game["period_text"] = "Q3"
elif self.current_game["period"] == 4: self.current_game["period_text"] = "Q4"
# Reset clock for next quarter (e.g., 15:00)
minutes, seconds = 15, 0
else:
@@ -892,9 +1046,29 @@ class NCAAFBLiveManager(BaseNCAAFBManager): # Renamed class
status_y = 1 # Position at top
self._draw_text_with_outline(draw_overlay, period_clock_text, (status_x, status_y), self.fonts['time'])
# Down & Distance (Below Period/Clock)
# Down & Distance or Scoring Event (Below Period/Clock)
scoring_event = game.get("scoring_event", "")
down_distance = game.get("down_distance_text", "")
if down_distance and game.get("is_live"): # Only show if live and available
# Show scoring event if detected, otherwise show down & distance
if scoring_event and game.get("is_live"):
# Display scoring event with special formatting
event_width = draw_overlay.textlength(scoring_event, font=self.fonts['detail'])
event_x = (self.display_width - event_width) // 2
event_y = (self.display_height) - 7
# Color coding for different scoring events
if scoring_event == "TOUCHDOWN":
event_color = (255, 215, 0) # Gold
elif scoring_event == "FIELD GOAL":
event_color = (0, 255, 0) # Green
elif scoring_event == "PAT":
event_color = (255, 165, 0) # Orange
else:
event_color = (255, 255, 255) # White
self._draw_text_with_outline(draw_overlay, scoring_event, (event_x, event_y), self.fonts['detail'], fill=event_color)
elif down_distance and game.get("is_live"): # Only show if live and available
dd_width = draw_overlay.textlength(down_distance, font=self.fonts['detail'])
dd_x = (self.display_width - dd_width) // 2
dd_y = (self.display_height)- 7 # Top of D&D text
@@ -1009,32 +1183,61 @@ class NCAAFBRecentManager(BaseNCAAFBManager): # Renamed class
events = data['events']
# self.logger.info(f"[NCAAFB Recent] Processing {len(events)} events from shared data.") # Changed log prefix
# Process games and filter for final games & favorite teams
# Define date range for "recent" games (last 21 days to capture games from 3 weeks ago)
now = datetime.now(timezone.utc)
recent_cutoff = now - timedelta(days=21)
self.logger.info(f"[NCAAFB Recent DEBUG] Current time: {now}, Recent cutoff: {recent_cutoff} (21 days ago)")
# Process games and filter for final games, date range & favorite teams
processed_games = []
favorite_games_found = 0
for event in events:
game = self._extract_game_details(event)
# Filter criteria: must be final
# Filter criteria: must be final AND within recent date range
if game and game['is_final']:
game_time = game.get('start_time_utc')
if game_time and game_time >= recent_cutoff:
processed_games.append(game)
# Count favorite team games for logging
if (game['home_abbr'] in self.favorite_teams or
game['away_abbr'] in self.favorite_teams):
favorite_games_found += 1
# Special check for Tennessee game in recent games
if (game['home_abbr'] == 'TENN' and game['away_abbr'] == 'UGA') or (game['home_abbr'] == 'UGA' and game['away_abbr'] == 'TENN'):
self.logger.info(f"[NCAAFB Recent DEBUG] Found Tennessee game in recent: {game['away_abbr']} @ {game['home_abbr']} - {game.get('start_time_utc')} - Score: {game['away_score']}-{game['home_score']}")
# Filter for favorite teams
if self.favorite_teams:
team_games = [game for game in processed_games
# Get all games involving favorite teams
favorite_team_games = [game for game in processed_games
if game['home_abbr'] in self.favorite_teams or
game['away_abbr'] in self.favorite_teams]
self.logger.info(f"[NCAAFB Recent] Found {favorite_games_found} favorite team games out of {len(processed_games)} total final games")
else:
team_games = processed_games # Show all recent games if no favorites defined
self.logger.info(f"[NCAAFB Recent] Found {len(processed_games)} total final games (no favorite teams configured)")
self.logger.info(f"[NCAAFB Recent] Found {favorite_games_found} favorite team games out of {len(processed_games)} total final games within last 21 days")
# Sort by game time, most recent first
# Select one game per favorite team (most recent game for each team)
team_games = []
for team in self.favorite_teams:
# Find games where this team is playing
team_specific_games = [game for game in favorite_team_games
if game['home_abbr'] == team or game['away_abbr'] == team]
if team_specific_games:
# Sort by game time and take the most recent
team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
team_games.append(team_specific_games[0])
# Sort the final list by game time (most recent first)
team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
# Debug: Show which games are selected for display
for i, game in enumerate(team_games):
self.logger.info(f"[NCAAFB Recent DEBUG] Game {i+1} for display: {game['away_abbr']} @ {game['home_abbr']} - {game.get('start_time_utc')} - Score: {game['away_score']}-{game['home_score']}")
else:
team_games = processed_games # Show all recent games if no favorites defined
self.logger.info(f"[NCAAFB Recent] Found {len(processed_games)} total final games within last 21 days (no favorite teams configured)")
# Sort by game time, most recent first
team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
# Limit to the specified number of recent games
team_games = team_games[:self.recent_games_to_show]
@@ -1287,8 +1490,14 @@ class NCAAFBUpcomingManager(BaseNCAAFBManager): # Renamed class
processed_games = []
favorite_games_found = 0
all_upcoming_games = 0 # Count all upcoming games regardless of favorites
for event in events:
game = self._extract_game_details(event)
# Count all upcoming games for debugging
if game and game['is_upcoming']:
all_upcoming_games += 1
# Filter criteria: must be upcoming ('pre' state)
if game and game['is_upcoming']:
# Only fetch odds for games that will be displayed
@@ -1305,20 +1514,75 @@ class NCAAFBUpcomingManager(BaseNCAAFBManager): # Renamed class
if self.show_odds:
self._fetch_odds(game)
# Summary logging instead of verbose debug
self.logger.info(f"[NCAAFB Upcoming] Found {len(processed_games)} total upcoming games")
# Enhanced logging for debugging
self.logger.info(f"[NCAAFB Upcoming] Found {all_upcoming_games} total upcoming games in data")
self.logger.info(f"[NCAAFB Upcoming] Found {len(processed_games)} upcoming games after filtering")
# Debug: Check what statuses we're seeing
status_counts = {}
status_names = {} # Track actual status names from ESPN
favorite_team_games = []
for event in events:
game = self._extract_game_details(event)
if game:
status = "upcoming" if game['is_upcoming'] else "final" if game['is_final'] else "live" if game['is_live'] else "other"
status_counts[status] = status_counts.get(status, 0) + 1
# Track actual ESPN status names
actual_status = event.get('competitions', [{}])[0].get('status', {}).get('type', {})
status_name = actual_status.get('name', 'Unknown')
status_state = actual_status.get('state', 'Unknown')
status_names[f"{status_name} ({status_state})"] = status_names.get(f"{status_name} ({status_state})", 0) + 1
# Check for favorite team games regardless of status
if (game['home_abbr'] in self.favorite_teams or game['away_abbr'] in self.favorite_teams):
favorite_team_games.append({
'teams': f"{game['away_abbr']} @ {game['home_abbr']}",
'status': status,
'date': game.get('start_time_utc', 'Unknown'),
'espn_status': f"{status_name} ({status_state})"
})
# Special check for Tennessee game (Georgia @ Tennessee)
if (game['home_abbr'] == 'TENN' and game['away_abbr'] == 'UGA') or (game['home_abbr'] == 'UGA' and game['away_abbr'] == 'TENN'):
self.logger.info(f"[NCAAFB DEBUG] Found Tennessee game: {game['away_abbr']} @ {game['home_abbr']} - {status} - {game.get('start_time_utc')} - ESPN: {status_name} ({status_state})")
self.logger.info(f"[NCAAFB Upcoming] Status breakdown: {status_counts}")
self.logger.info(f"[NCAAFB Upcoming] ESPN status names: {status_names}")
if favorite_team_games:
self.logger.info(f"[NCAAFB Upcoming] Favorite team games found: {len(favorite_team_games)}")
for game in favorite_team_games[:3]: # Show first 3
self.logger.info(f"[NCAAFB Upcoming] {game['teams']} - {game['status']} - {game['date']} - ESPN: {game['espn_status']}")
if self.favorite_teams and all_upcoming_games > 0:
self.logger.info(f"[NCAAFB Upcoming] Favorite teams: {self.favorite_teams}")
self.logger.info(f"[NCAAFB Upcoming] Found {favorite_games_found} favorite team upcoming games")
# Filter for favorite teams only if the config is set
if self.ncaa_fb_config.get("show_favorite_teams_only", False):
team_games = [game for game in processed_games
# Get all games involving favorite teams
favorite_team_games = [game for game in processed_games
if game['home_abbr'] in self.favorite_teams or
game['away_abbr'] in self.favorite_teams]
# Select one game per favorite team (earliest upcoming game for each team)
team_games = []
for team in self.favorite_teams:
# Find games where this team is playing
team_specific_games = [game for game in favorite_team_games
if game['home_abbr'] == team or game['away_abbr'] == team]
if team_specific_games:
# Sort by game time and take the earliest
team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc))
team_games.append(team_specific_games[0])
# Sort the final list by game time
team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc))
else:
team_games = processed_games # Show all upcoming if no favorites
# Sort by game time, earliest first
team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc))
# Limit to the specified number of upcoming games
team_games = team_games[:self.upcoming_games_to_show]

View File

@@ -828,12 +828,27 @@ class NCAAMBasketballRecentManager(BaseNCAAMBasketballManager):
# Filter for favorite teams only if the config is set
if self.ncaam_basketball_config.get("show_favorite_teams_only", False):
new_team_games = [game for game in new_recent_games
# Get all games involving favorite teams
favorite_team_games = [game for game in new_recent_games
if game['home_abbr'] in self.favorite_teams or
game['away_abbr'] in self.favorite_teams]
# Select one game per favorite team (most recent game for each team)
new_team_games = []
for team in self.favorite_teams:
# Find games where this team is playing
team_specific_games = [game for game in favorite_team_games
if game['home_abbr'] == team or game['away_abbr'] == team]
if team_specific_games:
# Sort by game time and take the most recent
team_specific_games.sort(key=lambda g: g.get('start_time_utc', datetime.min.replace(tzinfo=timezone.utc)), reverse=True)
new_team_games.append(team_specific_games[0])
# Sort the final list by game time (most recent first)
new_team_games.sort(key=lambda g: g.get('start_time_utc', datetime.min.replace(tzinfo=timezone.utc)), reverse=True)
else:
new_team_games = new_recent_games
# Sort by game time (most recent first), then limit to recent_games_to_show
new_team_games.sort(key=lambda g: g.get('start_time_utc', datetime.min.replace(tzinfo=timezone.utc)), reverse=True)
new_team_games = new_team_games[:self.recent_games_to_show]
@@ -964,12 +979,27 @@ class NCAAMBasketballUpcomingManager(BaseNCAAMBasketballManager):
# Filter for favorite teams only if the config is set
if self.ncaam_basketball_config.get("show_favorite_teams_only", False):
team_games = [game for game in new_upcoming_games
# Get all games involving favorite teams
favorite_team_games = [game for game in new_upcoming_games
if game['home_abbr'] in self.favorite_teams or
game['away_abbr'] in self.favorite_teams]
# Select one game per favorite team (earliest upcoming game for each team)
team_games = []
for team in self.favorite_teams:
# Find games where this team is playing
team_specific_games = [game for game in favorite_team_games
if game['home_abbr'] == team or game['away_abbr'] == team]
if team_specific_games:
# Sort by game time and take the earliest
team_specific_games.sort(key=lambda g: g.get('start_time_utc', datetime.max.replace(tzinfo=timezone.utc)))
team_games.append(team_specific_games[0])
# Sort the final list by game time
team_games.sort(key=lambda g: g.get('start_time_utc', datetime.max.replace(tzinfo=timezone.utc)))
else:
team_games = new_upcoming_games
# Sort by game time (soonest first), then limit to configured count
team_games.sort(key=lambda g: g.get('start_time_utc', datetime.max.replace(tzinfo=timezone.utc)))
team_games = team_games[:self.upcoming_games_to_show]

View File

@@ -351,16 +351,38 @@ class BaseNFLManager: # Renamed class
situation = competition.get("situation")
down_distance_text = ""
possession_indicator = None # Default to None
scoring_event = "" # Track scoring events
if situation and status["type"]["state"] == "in":
down = situation.get("down")
distance = situation.get("distance")
if down and distance is not None:
# Validate down and distance values before formatting
if (down is not None and isinstance(down, int) and 1 <= down <= 4 and
distance is not None and isinstance(distance, int) and distance >= 0):
down_str = {1: "1st", 2: "2nd", 3: "3rd", 4: "4th"}.get(down, f"{down}th")
dist_str = f"& {distance}" if distance > 0 else "& Goal"
down_distance_text = f"{down_str} {dist_str}"
elif situation.get("isRedZone"):
down_distance_text = "Red Zone" # Simplified if down/distance not present but in redzone
# Detect scoring events from status detail
status_detail = status["type"].get("detail", "").lower()
status_short = status["type"].get("shortDetail", "").lower()
# Check for scoring events in status text
if any(keyword in status_detail for keyword in ["touchdown", "td"]):
scoring_event = "TOUCHDOWN"
elif any(keyword in status_detail for keyword in ["field goal", "fg"]):
scoring_event = "FIELD GOAL"
elif any(keyword in status_detail for keyword in ["extra point", "pat", "point after"]):
scoring_event = "PAT"
elif any(keyword in status_short for keyword in ["touchdown", "td"]):
scoring_event = "TOUCHDOWN"
elif any(keyword in status_short for keyword in ["field goal", "fg"]):
scoring_event = "FIELD GOAL"
elif any(keyword in status_short for keyword in ["extra point", "pat"]):
scoring_event = "PAT"
# Determine possession based on team ID
possession_team_id = situation.get("possession")
if possession_team_id:
@@ -421,6 +443,7 @@ class BaseNFLManager: # Renamed class
"down_distance_text": down_distance_text, # Added Down/Distance
"possession": situation.get("possession") if situation else None, # ID of team with possession
"possession_indicator": possession_indicator, # Added for easy home/away check
"scoring_event": scoring_event, # Track scoring events (TOUCHDOWN, FIELD GOAL, PAT)
}
# Basic validation (can be expanded)
@@ -713,9 +736,29 @@ class NFLLiveManager(BaseNFLManager): # Renamed class
status_y = 1 # Position at top
self._draw_text_with_outline(draw_overlay, period_clock_text, (status_x, status_y), self.fonts['time'])
# Down & Distance (Below Period/Clock)
# Down & Distance or Scoring Event (Below Period/Clock)
scoring_event = game.get("scoring_event", "")
down_distance = game.get("down_distance_text", "")
if down_distance and game.get("is_live"): # Only show if live and available
# Show scoring event if detected, otherwise show down & distance
if scoring_event and game.get("is_live"):
# Display scoring event with special formatting
event_width = draw_overlay.textlength(scoring_event, font=self.fonts['detail'])
event_x = (self.display_width - event_width) // 2
event_y = (self.display_height) - 7
# Color coding for different scoring events
if scoring_event == "TOUCHDOWN":
event_color = (255, 215, 0) # Gold
elif scoring_event == "FIELD GOAL":
event_color = (0, 255, 0) # Green
elif scoring_event == "PAT":
event_color = (255, 165, 0) # Orange
else:
event_color = (255, 255, 255) # White
self._draw_text_with_outline(draw_overlay, scoring_event, (event_x, event_y), self.fonts['detail'], fill=event_color)
elif down_distance and game.get("is_live"): # Only show if live and available
dd_width = draw_overlay.textlength(down_distance, font=self.fonts['detail'])
dd_x = (self.display_width - dd_width) // 2
dd_y = (self.display_height)- 7 # Top of D&D text
@@ -850,15 +893,29 @@ class NFLRecentManager(BaseNFLManager): # Renamed class
# Filter for favorite teams only if the config is set
if self.nfl_config.get("show_favorite_teams_only", False):
team_games = [game for game in processed_games
# Get all games involving favorite teams
favorite_team_games = [game for game in processed_games
if game['home_abbr'] in self.favorite_teams or
game['away_abbr'] in self.favorite_teams]
# Select one game per favorite team (most recent game for each team)
team_games = []
for team in self.favorite_teams:
# Find games where this team is playing
team_specific_games = [game for game in favorite_team_games
if game['home_abbr'] == team or game['away_abbr'] == team]
if team_specific_games:
# Sort by game time and take the most recent
team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=self._get_timezone()), reverse=True)
team_games.append(team_specific_games[0])
# Sort the final list by game time (most recent first)
team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=self._get_timezone()), reverse=True)
else:
team_games = processed_games # Show all recent games if no favorites defined
# Sort by game time, most recent first
team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=self._get_timezone()), reverse=True)
# Limit to the specified number of recent games (default 5)
recent_games_to_show = self.nfl_config.get("recent_games_to_show", 5)
team_games = team_games[:recent_games_to_show]
@@ -1075,15 +1132,29 @@ class NFLUpcomingManager(BaseNFLManager): # Renamed class
# This check is now partially redundant if show_favorite_teams_only is true, but acts as the main filter otherwise
if self.nfl_config.get("show_favorite_teams_only", False):
team_games = [game for game in processed_games
# Get all games involving favorite teams
favorite_team_games = [game for game in processed_games
if game['home_abbr'] in self.favorite_teams or
game['away_abbr'] in self.favorite_teams]
# Select one game per favorite team (earliest upcoming game for each team)
team_games = []
for team in self.favorite_teams:
# Find games where this team is playing
team_specific_games = [game for game in favorite_team_games
if game['home_abbr'] == team or game['away_abbr'] == team]
if team_specific_games:
# Sort by game time and take the earliest
team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=self._get_timezone()))
team_games.append(team_specific_games[0])
# Sort the final list by game time
team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=self._get_timezone()))
else:
team_games = processed_games # Show all upcoming if no favorites
# Sort by game time, earliest first
team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=self._get_timezone()))
# Limit to the specified number of upcoming games (default 10)
upcoming_games_to_show = self.nfl_config.get("upcoming_games_to_show", 10)
self.logger.debug(f"[NFL Upcoming] Limiting to {upcoming_games_to_show} games (found {len(team_games)} total)")

View File

@@ -700,12 +700,27 @@ class NHLRecentManager(BaseNHLManager):
# Filter for favorite teams only if the config is set
if self.nhl_config.get("show_favorite_teams_only", False):
team_games = [game for game in processed_games
# Get all games involving favorite teams
favorite_team_games = [game for game in processed_games
if game['home_abbr'] in self.favorite_teams or
game['away_abbr'] in self.favorite_teams]
# Select one game per favorite team (most recent game for each team)
team_games = []
for team in self.favorite_teams:
# Find games where this team is playing
team_specific_games = [game for game in favorite_team_games
if game['home_abbr'] == team or game['away_abbr'] == team]
if team_specific_games:
# Sort by game time and take the most recent
team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
team_games.append(team_specific_games[0])
# Sort the final list by game time (most recent first)
team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
else:
team_games = processed_games
# Sort games by start time, most recent first, then limit to recent_games_to_show
team_games.sort(key=lambda x: x.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
team_games = team_games[:self.recent_games_to_show]
@@ -805,12 +820,27 @@ class NHLUpcomingManager(BaseNHLManager):
# Filter for favorite teams only if the config is set
if self.nhl_config.get("show_favorite_teams_only", False):
team_games = [game for game in new_upcoming_games
# Get all games involving favorite teams
favorite_team_games = [game for game in new_upcoming_games
if game['home_abbr'] in self.favorite_teams or
game['away_abbr'] in self.favorite_teams]
# Select one game per favorite team (earliest upcoming game for each team)
team_games = []
for team in self.favorite_teams:
# Find games where this team is playing
team_specific_games = [game for game in favorite_team_games
if game['home_abbr'] == team or game['away_abbr'] == team]
if team_specific_games:
# Sort by game time and take the earliest
team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc))
team_games.append(team_specific_games[0])
# Sort the final list by game time
team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc))
else:
team_games = new_upcoming_games
# Sort games by start time, soonest first, then limit to configured count
team_games.sort(key=lambda x: x.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc))
team_games = team_games[:self.upcoming_games_to_show]

View File

@@ -35,7 +35,17 @@ class OddsManager:
self.logger.info(f"Cache miss - fetching fresh odds from ESPN for {cache_key}")
try:
url = f"{self.base_url}/{sport}/leagues/{league}/events/{event_id}/competitions/{event_id}/odds"
# Map league names to ESPN API format
league_mapping = {
'ncaa_fb': 'college-football',
'nfl': 'nfl',
'nba': 'nba',
'mlb': 'mlb',
'nhl': 'nhl'
}
espn_league = league_mapping.get(league, league)
url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds"
self.logger.info(f"Requesting odds from URL: {url}")
response = requests.get(url, timeout=10)
response.raise_for_status()
@@ -46,13 +56,16 @@ class OddsManager:
self.logger.debug(f"Received raw odds data from ESPN: {json.dumps(raw_data, indent=2)}")
odds_data = self._extract_espn_data(raw_data)
self.logger.info(f"Extracted odds data: {odds_data}")
if odds_data:
self.logger.info(f"Successfully extracted odds data: {odds_data}")
else:
self.logger.debug("No odds data available for this game")
if odds_data:
self.cache_manager.set(cache_key, odds_data)
self.logger.info(f"Saved odds data to cache for {cache_key}")
else:
self.logger.warning(f"No odds data extracted for {cache_key}")
self.logger.debug(f"No odds data available for {cache_key}")
# Cache the fact that no odds are available to avoid repeated API calls
self.cache_manager.set(cache_key, {"no_odds": True})
@@ -91,7 +104,13 @@ class OddsManager:
self.logger.debug(f"Returning extracted odds data: {json.dumps(extracted_data, indent=2)}")
return extracted_data
# Log the actual response structure when no items are found
self.logger.warning("No 'items' found in ESPN odds data.")
self.logger.warning(f"Actual response structure: {json.dumps(data, indent=2)}")
# Check if this is a valid empty response or an unexpected structure
if "count" in data and data["count"] == 0 and "items" in data and data["items"] == []:
# This is a valid empty response - no odds available for this game
self.logger.debug(f"No odds available for this game. Response: {json.dumps(data, indent=2)}")
return None
else:
# This is an unexpected response structure
self.logger.warning("No 'items' found in ESPN odds data.")
self.logger.warning(f"Unexpected response structure: {json.dumps(data, indent=2)}")
return None

View File

@@ -119,6 +119,7 @@ class OddsTickerManager:
self.current_game_index = 0
self.ticker_image = None # This will hold the single, wide image
self.last_display_time = 0
self._end_reached_logged = False # Track if we've already logged reaching the end
# Font setup
self.fonts = self._load_fonts()
@@ -795,9 +796,16 @@ class OddsTickerManager:
elif sport == 'football':
quarter_text = f"Q{live_info.get('quarter', 1)}"
down_text = f"{live_info.get('down', 0)}&{live_info.get('distance', 0)}"
# Validate down and distance for odds ticker display
down = live_info.get('down')
distance = live_info.get('distance')
if (down is not None and isinstance(down, int) and 1 <= down <= 4 and
distance is not None and isinstance(distance, int) and distance >= 0):
down_text = f"{down}&{distance}"
else:
down_text = "" # Don't show invalid down/distance
clock_text = live_info.get('clock', '')
return f"[LIVE] {away_team_name} {away_score} vs {home_team_name} {home_score} - {quarter_text} {down_text} {clock_text}"
return f"[LIVE] {away_team_name} {away_score} vs {home_team_name} {home_score} - {quarter_text} {down_text} {clock_text}".strip()
elif sport == 'basketball':
quarter_text = f"Q{live_info.get('quarter', 1)}"
@@ -1106,7 +1114,14 @@ class OddsTickerManager:
elif sport == 'football':
# Football: Show quarter and down/distance
quarter_text = f"Q{live_info.get('quarter', 1)}"
down_text = f"{live_info.get('down', 0)}&{live_info.get('distance', 0)}"
# Validate down and distance for odds ticker display
down = live_info.get('down')
distance = live_info.get('distance')
if (down is not None and isinstance(down, int) and 1 <= down <= 4 and
distance is not None and isinstance(distance, int) and distance >= 0):
down_text = f"{down}&{distance}"
else:
down_text = "" # Don't show invalid down/distance
clock_text = live_info.get('clock', '')
day_text = quarter_text
@@ -1476,9 +1491,9 @@ class OddsTickerManager:
return
gap_width = 24 # Reduced gap between games
display_width = self.display_manager.matrix.width # Add display width of black space at start
display_width = self.display_manager.matrix.width # Add display width of black space at start and end
content_width = sum(img.width for img in game_images) + gap_width * (len(game_images))
total_width = display_width + content_width
total_width = display_width + content_width + display_width # Add display width at both start and end
height = self.display_manager.matrix.height
logger.debug(f"Image creation details:")
@@ -1504,7 +1519,7 @@ class OddsTickerManager:
# Calculate total scroll width for dynamic duration (only the content width, not including display width)
self.total_scroll_width = content_width
logger.debug(f"Odds ticker image creation:")
logger.debug(f" Display width: {display_width}px")
logger.debug(f" Display width: {display_width}px (added at start and end)")
logger.debug(f" Content width: {content_width}px")
logger.debug(f" Total image width: {total_width}px")
logger.debug(f" Number of games: {len(game_images)}")
@@ -1701,6 +1716,8 @@ class OddsTickerManager:
logger.debug(f"Reset/initialized display start time: {self._display_start_time}")
# Also reset scroll position for clean start
self.scroll_position = 0
# Reset the end reached logging flag
self._end_reached_logged = False
else:
# Check if the display start time is too old (more than 2x the dynamic duration)
current_time = time.time()
@@ -1709,6 +1726,8 @@ class OddsTickerManager:
logger.debug(f"Display start time is too old ({elapsed_time:.1f}s), resetting")
self._display_start_time = current_time
self.scroll_position = 0
# Reset the end reached logging flag
self._end_reached_logged = False
logger.debug(f"Number of games in data at start of display method: {len(self.games_data)}")
if not self.games_data:
@@ -1812,11 +1831,13 @@ class OddsTickerManager:
else:
# Stop scrolling when we reach the end
if self.scroll_position >= self.ticker_image.width - width:
if not self._end_reached_logged:
logger.info(f"Odds ticker reached end: scroll_position {self.scroll_position} >= {self.ticker_image.width - width}")
logger.info("Odds ticker scrolling stopped - reached end of content")
self._end_reached_logged = True
self.scroll_position = self.ticker_image.width - width
# Signal that scrolling has stopped
self.display_manager.set_scrolling_state(False)
logger.info("Odds ticker scrolling stopped - reached end of content")
# Check if we're at a natural break point for mode switching
# If we're near the end of the display duration and not at a clean break point,

View File

@@ -5,7 +5,10 @@ from datetime import date
from PIL import ImageDraw, ImageFont
from src.config_manager import ConfigManager
import time
import freetype
try:
import freetype
except ImportError:
freetype = None
# Configure logger for this module
logger = logging.getLogger(__name__)
@@ -36,17 +39,56 @@ class OfTheDayManager:
# Load fonts with robust path resolution and fallbacks
try:
# Try multiple font directory locations
script_dir = os.path.dirname(os.path.abspath(__file__))
font_dir = os.path.abspath(os.path.join(script_dir, '..', 'assets', 'fonts'))
possible_font_dirs = [
os.path.abspath(os.path.join(script_dir, '..', 'assets', 'fonts')), # Relative to src/
os.path.abspath(os.path.join(os.getcwd(), 'assets', 'fonts')), # Relative to project root
os.path.abspath('assets/fonts'), # Simple relative path made absolute
'assets/fonts' # Simple relative path
]
font_dir = None
for potential_dir in possible_font_dirs:
if os.path.exists(potential_dir):
font_dir = potential_dir
logger.debug(f"Found font directory at: {font_dir}")
break
if font_dir is None:
logger.warning("No font directory found, using fallback fonts")
raise FileNotFoundError("Font directory not found")
def _safe_load_bdf_font(filename):
try:
font_path = os.path.abspath(os.path.join(font_dir, filename))
if not os.path.exists(font_path):
raise FileNotFoundError(f"Font file not found: {font_path}")
return freetype.Face(font_path)
# Try multiple font paths
font_paths = [
os.path.abspath(os.path.join(font_dir, filename)),
os.path.join(font_dir, filename),
os.path.join(script_dir, '..', 'assets', 'fonts', filename),
os.path.join(script_dir, '..', 'assets', 'fonts', filename)
]
for font_path in font_paths:
abs_font_path = os.path.abspath(font_path)
if os.path.exists(abs_font_path):
logger.debug(f"Loading BDF font: {abs_font_path}")
if freetype is not None:
return freetype.Face(abs_font_path)
else:
logger.warning("freetype module not available, cannot load BDF fonts")
return None
logger.debug(f"Font file not found: {filename}")
# List available fonts for debugging
try:
available_fonts = [f for f in os.listdir(font_dir) if f.endswith('.bdf')]
logger.debug(f"Available BDF fonts in {font_dir}: {available_fonts}")
except:
pass
return None
except Exception as e:
logger.error(f"Failed to load BDF font '{filename}': {e}")
logger.debug(f"Failed to load BDF font '{filename}': {e}")
return None
self.title_font = _safe_load_bdf_font('ic8x8u.bdf')
@@ -55,19 +97,19 @@ class OfTheDayManager:
# Fallbacks if BDF fonts aren't available
if self.title_font is None:
self.title_font = getattr(self.display_manager, 'bdf_5x7_font', None) or getattr(self.display_manager, 'small_font', ImageFont.load_default())
logger.warning("Using fallback font for title in OfTheDayManager")
logger.info("Using fallback font for title in OfTheDayManager")
if self.body_font is None:
self.body_font = getattr(self.display_manager, 'bdf_5x7_font', None) or getattr(self.display_manager, 'small_font', ImageFont.load_default())
logger.warning("Using fallback font for body in OfTheDayManager")
logger.info("Using fallback font for body in OfTheDayManager")
# Log font types for debugging
logger.debug(f"Title font type: {type(self.title_font).__name__}")
logger.debug(f"Body font type: {type(self.body_font).__name__}")
except Exception as e:
logger.error(f"Unexpected error during font initialization: {e}")
logger.warning(f"Error during font initialization, using fallbacks: {e}")
# Last-resort fallback
self.title_font = ImageFont.load_default()
self.body_font = ImageFont.load_default()
self.title_font = getattr(self.display_manager, 'small_font', ImageFont.load_default())
self.body_font = getattr(self.display_manager, 'small_font', ImageFont.load_default())
# Load categories and their data
self.categories = self.of_the_day_config.get('categories', {})
@@ -108,6 +150,16 @@ class OfTheDayManager:
logger.info(f"Current working directory: {os.getcwd()}")
logger.info(f"Script directory: {os.path.dirname(__file__)}")
# Additional debugging for Pi environment
logger.debug(f"Absolute script directory: {os.path.abspath(os.path.dirname(__file__))}")
logger.debug(f"Absolute working directory: {os.path.abspath(os.getcwd())}")
# Check if we're running on Pi
if os.path.exists('/home/ledpi'):
logger.debug("Detected Pi environment (/home/ledpi exists)")
else:
logger.debug("Not running on Pi environment")
for category_name, category_config in self.categories.items():
logger.debug(f"Processing category: {category_name}")
if not category_config.get('enabled', True):
@@ -120,17 +172,95 @@ class OfTheDayManager:
continue
try:
# Try relative path first, then absolute
file_path = data_file
if not os.path.isabs(file_path):
# If data_file already contains 'of_the_day/', use it as is
if data_file.startswith('of_the_day/'):
file_path = os.path.join(os.path.dirname(__file__), '..', data_file)
else:
file_path = os.path.join(os.path.dirname(__file__), '..', 'of_the_day', data_file)
# Try multiple possible paths for data files
script_dir = os.path.dirname(os.path.abspath(__file__))
current_dir = os.getcwd()
project_root = os.path.dirname(script_dir) # Go up one level from src/ to project root
possible_paths = []
logger.debug(f"Script directory: {script_dir}")
logger.debug(f"Current working directory: {current_dir}")
logger.debug(f"Project root directory: {project_root}")
logger.debug(f"Data file from config: {data_file}")
if os.path.isabs(data_file):
possible_paths.append(data_file)
else:
# Always try multiple paths regardless of how data_file is specified
possible_paths.extend([
os.path.join(current_dir, data_file), # Current working directory first
os.path.join(project_root, data_file), # Project root directory
os.path.join(script_dir, '..', data_file), # Relative to script directory
data_file # Direct path
])
# If data_file doesn't already contain 'of_the_day/', also try with it
if not data_file.startswith('of_the_day/'):
possible_paths.extend([
os.path.join(current_dir, 'of_the_day', os.path.basename(data_file)),
os.path.join(project_root, 'of_the_day', os.path.basename(data_file)),
os.path.join(script_dir, '..', 'of_the_day', os.path.basename(data_file)),
os.path.join('of_the_day', os.path.basename(data_file))
])
else:
# If data_file already contains 'of_the_day/', try extracting just the filename
filename = os.path.basename(data_file)
possible_paths.extend([
os.path.join(current_dir, 'of_the_day', filename),
os.path.join(project_root, 'of_the_day', filename),
os.path.join(script_dir, '..', 'of_the_day', filename),
os.path.join('of_the_day', filename)
])
# Debug: Show all paths before deduplication
logger.debug(f"All possible paths before deduplication: {possible_paths}")
# Remove duplicates while preserving order
seen = set()
unique_paths = []
for path in possible_paths:
abs_path = os.path.abspath(path)
if abs_path not in seen:
seen.add(abs_path)
unique_paths.append(abs_path)
possible_paths = unique_paths
# Debug: Show paths after deduplication
logger.debug(f"Unique paths after deduplication: {possible_paths}")
file_path = None
for potential_path in possible_paths:
abs_path = os.path.abspath(potential_path)
if os.path.exists(abs_path):
file_path = abs_path
logger.debug(f"Found data file for {category_name} at: {file_path}")
break
# Final fallback - try the direct path relative to current working directory
if file_path is None:
direct_path = os.path.join(current_dir, 'of_the_day', os.path.basename(data_file))
if os.path.exists(direct_path):
file_path = direct_path
logger.debug(f"Found data file for {category_name} using direct fallback: {file_path}")
if file_path is None:
# Use the first attempted path for error reporting
file_path = os.path.abspath(possible_paths[0])
logger.debug(f"No data file found for {category_name}, tried: {[os.path.abspath(p) for p in possible_paths]}")
# Additional debugging - check if parent directory exists
parent_dir = os.path.dirname(file_path)
logger.debug(f"Parent directory: {parent_dir}")
logger.debug(f"Parent directory exists: {os.path.exists(parent_dir)}")
if os.path.exists(parent_dir):
try:
parent_contents = os.listdir(parent_dir)
logger.debug(f"Parent directory contents: {parent_contents}")
except PermissionError:
logger.debug(f"Permission denied accessing parent directory: {parent_dir}")
except Exception as e:
logger.debug(f"Error listing parent directory: {e}")
# Convert to absolute path for better logging
file_path = os.path.abspath(file_path)
logger.debug(f"Attempting to load {category_name} from: {file_path}")
if os.path.exists(file_path):
@@ -156,7 +286,18 @@ class OfTheDayManager:
else:
logger.error(f"Data file not found for {category_name}: {file_path}")
logger.error(f"Directory contents: {os.listdir(os.path.dirname(file_path)) if os.path.exists(os.path.dirname(file_path)) else 'Parent directory does not exist'}")
parent_dir = os.path.dirname(file_path)
if os.path.exists(parent_dir):
try:
dir_contents = os.listdir(parent_dir)
logger.error(f"Directory contents of {parent_dir}: {dir_contents}")
except PermissionError:
logger.error(f"Permission denied accessing directory: {parent_dir}")
except Exception as e:
logger.error(f"Error listing directory {parent_dir}: {e}")
else:
logger.error(f"Parent directory does not exist: {parent_dir}")
logger.error(f"Tried paths: {[os.path.abspath(p) for p in possible_paths]}")
self.data_files[category_name] = {}
except json.JSONDecodeError as e:
@@ -240,15 +381,26 @@ class OfTheDayManager:
"""Draw text for both BDF (FreeType Face) and PIL TTF fonts."""
try:
# If we have a PIL font, use native text rendering
if not isinstance(face, freetype.Face):
if freetype is None or not isinstance(face, freetype.Face):
draw.text((x, y), text, fill=color, font=face)
return
# Compute baseline from font ascender so caller can pass top-left y
try:
ascender_px = face.size.ascender >> 6
except Exception:
ascender_px = 0
baseline_y = y + ascender_px
# Otherwise, render BDF glyphs manually
for char in text:
face.load_char(char)
bitmap = face.glyph.bitmap
# Get glyph metrics
glyph_left = face.glyph.bitmap_left
glyph_top = face.glyph.bitmap_top
for i in range(bitmap.rows):
for j in range(bitmap.width):
try:
@@ -256,9 +408,12 @@ class OfTheDayManager:
if byte_index < len(bitmap.buffer):
byte = bitmap.buffer[byte_index]
if byte & (1 << (7 - (j % 8))):
draw_y = y - face.glyph.bitmap_top + i
draw_x = x + face.glyph.bitmap_left + j
draw.point((draw_x, draw_y), fill=color)
# Calculate actual pixel position
pixel_x = x + glyph_left + j
pixel_y = baseline_y - glyph_top + i
# Only draw if within bounds
if (0 <= pixel_x < self.display_manager.width and 0 <= pixel_y < self.display_manager.height):
draw.point((pixel_x, pixel_y), fill=color)
except IndexError:
logger.warning(f"Index out of range for char '{char}' at position ({i}, {j})")
continue
@@ -287,10 +442,54 @@ class OfTheDayManager:
except Exception:
body_height = 8
# --- Draw Title (always at top) ---
title_y = title_height # Position title so its bottom is at title_height
# --- Dynamic Spacing Calculation ---
# Calculate how much space we need and distribute it evenly
margin_top = 8 # Shift everything down by 6 pixels
margin_bottom = 1
underline_space = 1 # Space for underline
# Calculate title width for centering (robust to font type)
# Determine current content
current_text = subtitle if (self.rotation_state == 0 and subtitle) else description
if not current_text:
current_text = ""
# Pre-wrap the body text to determine how many lines we'll need
available_width = matrix_width - 4 # Leave some margin
wrapped_lines = self._wrap_text(current_text, available_width, body_font, max_lines=10,
line_height=body_height, max_height=matrix_height)
# Filter out empty lines for spacing calculation
actual_body_lines = [line for line in wrapped_lines if line.strip()]
num_body_lines = len(actual_body_lines)
# Calculate total content height needed
title_content_height = title_height
underline_content_height = underline_space
body_content_height = num_body_lines * body_height if num_body_lines > 0 else 0
total_content_height = title_content_height + underline_content_height + body_content_height
available_space = matrix_height - margin_top - margin_bottom
# Calculate dynamic spacing
if total_content_height < available_space:
# We have extra space - distribute it
extra_space = available_space - total_content_height
if num_body_lines > 0:
# Distribute space: 30% after title, 70% between body lines
space_after_title = max(2, int(extra_space * 0.3))
space_between_lines = max(1, int(extra_space * 0.7 / max(1, num_body_lines - 1))) if num_body_lines > 1 else 0
else:
# No body text - just center the title
space_after_title = extra_space // 2
space_between_lines = 0
else:
# Tight spacing
space_after_title = 4
space_between_lines = 1
# --- Draw Title ---
title_y = margin_top
# Calculate title width for centering
try:
title_width = self.display_manager.get_text_width(title, title_font)
except Exception:
@@ -300,23 +499,18 @@ class OfTheDayManager:
title_x = (matrix_width - title_width) // 2
self._draw_bdf_text(draw, title_font, title, title_x, title_y, color=self.title_color)
# Underline below title (centered)
underline_y = title_height + 1
# --- Draw Underline ---
underline_y = title_y + title_height + 1 # Reduced space after title
underline_x_start = title_x
underline_x_end = title_x + title_width
draw.line([(underline_x_start, underline_y), (underline_x_end, underline_y)], fill=self.title_color, width=1)
# --- Draw Subtitle or Description (rotating) ---
# Start subtitle/description below the title and underline
# Account for title height + underline + spacing
y_start = title_height + body_height + 4 # Space for underline
available_height = matrix_height - y_start
available_width = matrix_width - 2
# --- Draw Body Text with Dynamic Spacing ---
if num_body_lines > 0:
body_start_y = underline_y + space_after_title + 1 # Shift description down 1 pixel
current_y = body_start_y
if self.rotation_state == 0 and subtitle:
# Show subtitle
wrapped = self._wrap_text(subtitle, available_width, body_font, max_lines=3, line_height=body_height, max_height=available_height)
for i, line in enumerate(wrapped):
for i, line in enumerate(actual_body_lines):
if line.strip(): # Only draw non-empty lines
# Center each line of body text
try:
@@ -324,24 +518,14 @@ class OfTheDayManager:
except Exception:
line_width = len(line) * 6
line_x = (matrix_width - line_width) // 2
# Add one pixel buffer between lines
line_y = y_start + i * (body_height + 1)
self._draw_bdf_text(draw, body_font, line, line_x, line_y, color=self.subtitle_color)
elif self.rotation_state == 1 and description:
# Show description
wrapped = self._wrap_text(description, available_width, body_font, max_lines=3, line_height=body_height, max_height=available_height)
for i, line in enumerate(wrapped):
if line.strip(): # Only draw non-empty lines
# Center each line of body text
try:
line_width = self.display_manager.get_text_width(line, body_font)
except Exception:
line_width = len(line) * 6
line_x = (matrix_width - line_width) // 2
# Add one pixel buffer between lines
line_y = y_start + i * (body_height + 1)
self._draw_bdf_text(draw, body_font, line, line_x, line_y, color=self.subtitle_color)
# else: nothing to show
# Draw the line
self._draw_bdf_text(draw, body_font, line, line_x, current_y, color=self.subtitle_color)
# Move to next line position
if i < len(actual_body_lines) - 1: # Not the last line
current_y += body_height + space_between_lines
return True
except Exception as e:
logger.error(f"Error drawing 'of the day' item: {e}", exc_info=True)

View File

@@ -12,6 +12,7 @@ from src.display_manager import DisplayManager
from src.cache_manager import CacheManager
from src.config_manager import ConfigManager
from src.odds_manager import OddsManager
from src.logo_downloader import download_missing_logo, get_soccer_league_key
import pytz
# Import the API counter function from web interface
@@ -32,6 +33,7 @@ LEAGUE_SLUGS = {
"ger.1": "Bundesliga",
"ita.1": "Serie A",
"fra.1": "Ligue 1",
"por.1": "Liga Portugal",
"uefa.champions": "Champions League",
"uefa.europa": "Europa League",
"usa.1": "MLS",
@@ -408,7 +410,25 @@ class BaseSoccerManager:
try:
if not os.path.exists(logo_path) and not (cache_logo_path and os.path.exists(cache_logo_path)):
self.logger.info(f"Creating placeholder logo for {team_abbrev}")
self.logger.info(f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download from ESPN.")
# Try to download the logo from ESPN API for each configured league
download_success = False
for league_code in self.target_leagues_config:
if league_code in LEAGUE_SLUGS:
soccer_league_key = get_soccer_league_key(league_code)
self.logger.debug(f"Attempting to download {team_abbrev} logo from {league_code} ({soccer_league_key})")
success = download_missing_logo(team_abbrev, soccer_league_key, team_abbrev)
if success:
self.logger.info(f"Successfully downloaded logo for {team_abbrev} from {league_code}")
download_success = True
break
else:
self.logger.debug(f"Failed to download {team_abbrev} logo from {league_code}")
if not download_success:
self.logger.warning(f"Failed to download logo for {team_abbrev} from any configured league. Creating placeholder.")
# Try to create placeholder in cache directory instead of assets directory
cache_logo_path = None
try:
@@ -423,6 +443,7 @@ class BaseSoccerManager:
draw = ImageDraw.Draw(logo)
# Optionally add text to placeholder
try:
script_dir = os.path.dirname(os.path.abspath(__file__))
font_4x6 = os.path.abspath(os.path.join(script_dir, "../assets/fonts/4x6-font.ttf"))
placeholder_font = ImageFont.truetype(font_4x6, 12)
text_width = draw.textlength(team_abbrev, font=placeholder_font)
@@ -971,10 +992,27 @@ class SoccerRecentManager(BaseSoccerManager):
# Filter for favorite teams only if the config is set
if self.soccer_config.get("show_favorite_teams_only", False):
team_games = [game for game in new_recent_games if game['home_abbr'] in self.favorite_teams or game['away_abbr'] in self.favorite_teams]
# Get all games involving favorite teams
favorite_team_games = [game for game in new_recent_games
if game['home_abbr'] in self.favorite_teams or
game['away_abbr'] in self.favorite_teams]
# Select one game per favorite team (most recent game for each team)
team_games = []
for team in self.favorite_teams:
# Find games where this team is playing
team_specific_games = [game for game in favorite_team_games
if game['home_abbr'] == team or game['away_abbr'] == team]
if team_specific_games:
# Sort by game time and take the most recent
team_specific_games.sort(key=lambda g: g['start_time_utc'], reverse=True)
team_games.append(team_specific_games[0])
# Sort the final list by game time (most recent first)
team_games.sort(key=lambda g: g['start_time_utc'], reverse=True)
else:
team_games = new_recent_games
# Sort games by start time, most recent first, and limit to recent_games_to_show
team_games.sort(key=lambda x: x['start_time_utc'], reverse=True)
team_games = team_games[:self.recent_games_to_show]
@@ -1078,10 +1116,27 @@ class SoccerUpcomingManager(BaseSoccerManager):
# Filter for favorite teams only if the config is set
if self.soccer_config.get("show_favorite_teams_only", False):
team_games = [game for game in new_upcoming_games if game['home_abbr'] in self.favorite_teams or game['away_abbr'] in self.favorite_teams]
# Get all games involving favorite teams
favorite_team_games = [game for game in new_upcoming_games
if game['home_abbr'] in self.favorite_teams or
game['away_abbr'] in self.favorite_teams]
# Select one game per favorite team (earliest upcoming game for each team)
team_games = []
for team in self.favorite_teams:
# Find games where this team is playing
team_specific_games = [game for game in favorite_team_games
if game['home_abbr'] == team or game['away_abbr'] == team]
if team_specific_games:
# Sort by game time and take the earliest
team_specific_games.sort(key=lambda g: g['start_time_utc'])
team_games.append(team_specific_games[0])
# Sort the final list by game time
team_games.sort(key=lambda g: g['start_time_utc'])
else:
team_games = new_upcoming_games
# Sort games by start time, soonest first, then limit to configured count
team_games.sort(key=lambda x: x['start_time_utc'])
team_games = team_games[:self.upcoming_games_to_show]

View File

@@ -719,11 +719,12 @@
<button class="btn btn-danger" onclick="runAction('stop_display')"><i class="fas fa-stop"></i> Stop Display</button>
<button class="btn btn-primary" onclick="systemAction('restart_service')"><i class="fas fa-redo"></i> Restart Service</button>
<button class="btn btn-warning" onclick="systemAction('git_pull')"><i class="fas fa-download"></i> Update Code</button>
<button class="btn btn-info" onclick="systemAction('migrate_config')"><i class="fas fa-sync-alt"></i> Migrate Config</button>
<button class="btn btn-danger" onclick="systemAction('reboot_system')"><i class="fas fa-power-off"></i> Reboot</button>
<button class="btn btn-secondary" onclick="stopOnDemand()"><i class="fas fa-ban"></i> Stop On-Demand</button>
<span id="ondemand-status" style="margin-left:auto; font-size:12px; color:#333; background:#f3f3f3; padding:6px 10px; border-radius:8px;">On-Demand: None</span>
</div>
<div style="margin-top:12px; color:#666; font-size:12px;">Service actions may require sudo privileges on the Pi.</div>
<div style="margin-top:12px; color:#666; font-size:12px;">Service actions may require sudo privileges on the Pi. Migrate Config adds new options with defaults while preserving your settings.</div>
</div>
<!-- Editor Mode Banner -->
@@ -876,11 +877,11 @@
<div class="stat-label">CPU Temperature</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ main_config.display.hardware.brightness }}</div>
<div class="stat-value">{{ main_config.get('display', {}).get('hardware', {}).get('brightness', 0) }}</div>
<div class="stat-label">Brightness</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ main_config.display.hardware.cols }}x{{ main_config.display.hardware.rows }}</div>
<div class="stat-value">{{ main_config.get('display', {}).get('hardware', {}).get('cols', 0) }}x{{ main_config.get('display', {}).get('hardware', {}).get('rows', 0) }}</div>
<div class="stat-label">Resolution</div>
</div>
<div class="stat-card">
@@ -906,6 +907,9 @@
<button class="btn btn-warning" onclick="systemAction('git_pull')">
<i class="fas fa-download"></i> Update Code
</button>
<button class="btn btn-info" onclick="systemAction('migrate_config')">
<i class="fas fa-sync-alt"></i> Migrate Config
</button>
<button class="btn btn-danger" onclick="systemAction('reboot_system')">
<i class="fas fa-power-off"></i> Reboot System
</button>
@@ -919,28 +923,28 @@
<form id="general-form">
<div class="form-group">
<label>
<input type="checkbox" id="web_display_autostart" name="web_display_autostart" {% if main_config.web_display_autostart %}checked{% endif %}>
<input type="checkbox" id="web_display_autostart" name="web_display_autostart" {% if safe_config_get(main_config, 'web_display_autostart', default=True) %}checked{% endif %}>
Web Display Autostart
</label>
<div class="description">Start the web interface on boot for easier access.</div>
</div>
<div class="form-group">
<label for="timezone">Timezone</label>
<input type="text" class="form-control" id="timezone" name="timezone" value="{{ main_config.timezone }}" placeholder="e.g., America/Chicago">
<input type="text" class="form-control" id="timezone" name="timezone" value="{{ safe_config_get(main_config, 'timezone', default='America/Chicago') }}" placeholder="e.g., America/Chicago">
<div class="description">IANA timezone, affects time-based features and scheduling.</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="city">City</label>
<input type="text" class="form-control" id="city" name="city" value="{{ main_config.location.city }}">
<input type="text" class="form-control" id="city" name="city" value="{{ safe_config_get(main_config, 'location', 'city', default='Dallas') }}">
</div>
<div class="form-group">
<label for="state">State</label>
<input type="text" class="form-control" id="state" name="state" value="{{ main_config.location.state }}">
<input type="text" class="form-control" id="state" name="state" value="{{ safe_config_get(main_config, 'location', 'state', default='Texas') }}">
</div>
<div class="form-group">
<label for="country">Country</label>
<input type="text" class="form-control" id="country" name="country" value="{{ main_config.location.country }}">
<input type="text" class="form-control" id="country" name="country" value="{{ safe_config_get(main_config, 'location', 'country', default='US') }}">
</div>
</div>
<button type="submit" class="btn btn-success">Save General Settings</button>
@@ -988,36 +992,36 @@
<div>
<div class="form-group">
<label for="rows">Rows:</label>
<input type="number" class="form-control" id="rows" name="rows" value="{{ main_config.display.hardware.rows }}" min="1" max="64">
<input type="number" class="form-control" id="rows" name="rows" value="{{ safe_config_get(main_config, 'display', 'hardware', 'rows', default=32) }}" min="1" max="64">
<div class="description">Number of LED rows</div>
</div>
<div class="form-group">
<label for="cols">Columns:</label>
<input type="number" class="form-control" id="cols" name="cols" value="{{ main_config.display.hardware.cols }}" min="1" max="128">
<input type="number" class="form-control" id="cols" name="cols" value="{{ safe_config_get(main_config, 'display', 'hardware', 'cols', default=64) }}" min="1" max="128">
<div class="description">Number of LED columns</div>
</div>
<div class="form-group">
<label for="chain_length">Chain Length:</label>
<input type="number" class="form-control" id="chain_length" name="chain_length" value="{{ main_config.display.hardware.chain_length }}" min="1" max="8">
<input type="number" class="form-control" id="chain_length" name="chain_length" value="{{ safe_config_get(main_config, 'display', 'hardware', 'chain_length', default=2) }}" min="1" max="8">
<div class="description">Number of LED panels chained together</div>
</div>
<div class="form-group">
<label for="parallel">Parallel:</label>
<input type="number" class="form-control" id="parallel" name="parallel" value="{{ main_config.display.hardware.parallel }}" min="1" max="4">
<input type="number" class="form-control" id="parallel" name="parallel" value="{{ safe_config_get(main_config, 'display', 'hardware', 'parallel', default=1) }}" min="1" max="4">
<div class="description">Number of parallel chains</div>
</div>
<div class="form-group">
<label for="brightness">Brightness:</label>
<input type="range" class="form-control" id="brightness" name="brightness" value="{{ main_config.display.hardware.brightness }}" min="1" max="100" oninput="updateBrightnessDisplay(this.value)">
<div class="description">LED brightness: <span id="brightness-value">{{ main_config.display.hardware.brightness }}</span>%</div>
<input type="range" class="form-control" id="brightness" name="brightness" value="{{ safe_config_get(main_config, 'display', 'hardware', 'brightness', default=95) }}" min="1" max="100" oninput="updateBrightnessDisplay(this.value)">
<div class="description">LED brightness: <span id="brightness-value">{{ safe_config_get(main_config, 'display', 'hardware', 'brightness', default=95) }}</span>%</div>
</div>
<div class="form-group">
<label for="hardware_mapping">Hardware Mapping:</label>
<select class="form-control" id="hardware_mapping" name="hardware_mapping">
<option value="adafruit-hat-pwm" {% if main_config.display.hardware.hardware_mapping == "adafruit-hat-pwm" %}selected{% endif %}>Adafruit HAT PWM</option>
<option value="adafruit-hat" {% if main_config.display.hardware.hardware_mapping == "adafruit-hat" %}selected{% endif %}>Adafruit HAT</option>
<option value="regular" {% if main_config.display.hardware.hardware_mapping == "regular" %}selected{% endif %}>Regular</option>
<option value="regular-pi1" {% if main_config.display.hardware.hardware_mapping == "regular-pi1" %}selected{% endif %}>Regular Pi1</option>
<option value="adafruit-hat-pwm" {% if safe_config_get(main_config, 'display', 'hardware', 'hardware_mapping', default='adafruit-hat-pwm') == "adafruit-hat-pwm" %}selected{% endif %}>Adafruit HAT PWM</option>
<option value="adafruit-hat" {% if safe_config_get(main_config, 'display', 'hardware', 'hardware_mapping', default='adafruit-hat-pwm') == "adafruit-hat" %}selected{% endif %}>Adafruit HAT</option>
<option value="regular" {% if safe_config_get(main_config, 'display', 'hardware', 'hardware_mapping', default='adafruit-hat-pwm') == "regular" %}selected{% endif %}>Regular</option>
<option value="regular-pi1" {% if safe_config_get(main_config, 'display', 'hardware', 'hardware_mapping', default='adafruit-hat-pwm') == "regular-pi1" %}selected{% endif %}>Regular Pi1</option>
</select>
<div class="description">Hardware mapping type</div>
</div>
@@ -1025,32 +1029,32 @@
<div>
<div class="form-group">
<label for="gpio_slowdown">GPIO Slowdown:</label>
<input type="number" class="form-control" id="gpio_slowdown" name="gpio_slowdown" value="{{ main_config.display.runtime.gpio_slowdown }}" min="0" max="5">
<input type="number" class="form-control" id="gpio_slowdown" name="gpio_slowdown" value="{{ safe_config_get(main_config, 'display', 'runtime', 'gpio_slowdown', default=3) }}" min="0" max="5">
<div class="description">GPIO slowdown factor (0-5)</div>
</div>
<div class="form-group">
<label for="scan_mode">Scan Mode:</label>
<input type="number" class="form-control" id="scan_mode" name="scan_mode" value="{{ main_config.display.hardware.scan_mode }}" min="0" max="1">
<input type="number" class="form-control" id="scan_mode" name="scan_mode" value="{{ safe_config_get(main_config, 'display', 'hardware', 'scan_mode', default=0) }}" min="0" max="1">
<div class="description">Scan mode for LED matrix (0-1)</div>
</div>
<div class="form-group">
<label for="pwm_bits">PWM Bits:</label>
<input type="number" class="form-control" id="pwm_bits" name="pwm_bits" value="{{ main_config.display.hardware.pwm_bits }}" min="1" max="11">
<input type="number" class="form-control" id="pwm_bits" name="pwm_bits" value="{{ safe_config_get(main_config, 'display', 'hardware', 'pwm_bits', default=9) }}" min="1" max="11">
<div class="description">PWM bits for brightness control (1-11)</div>
</div>
<div class="form-group">
<label for="pwm_dither_bits">PWM Dither Bits:</label>
<input type="number" class="form-control" id="pwm_dither_bits" name="pwm_dither_bits" value="{{ main_config.display.hardware.pwm_dither_bits }}" min="0" max="4">
<input type="number" class="form-control" id="pwm_dither_bits" name="pwm_dither_bits" value="{{ safe_config_get(main_config, 'display', 'hardware', 'pwm_dither_bits', default=1) }}" min="0" max="4">
<div class="description">PWM dither bits (0-4)</div>
</div>
<div class="form-group">
<label for="pwm_lsb_nanoseconds">PWM LSB Nanoseconds:</label>
<input type="number" class="form-control" id="pwm_lsb_nanoseconds" name="pwm_lsb_nanoseconds" value="{{ main_config.display.hardware.pwm_lsb_nanoseconds }}" min="50" max="500">
<input type="number" class="form-control" id="pwm_lsb_nanoseconds" name="pwm_lsb_nanoseconds" value="{{ safe_config_get(main_config, 'display', 'hardware', 'pwm_lsb_nanoseconds', default=130) }}" min="50" max="500">
<div class="description">PWM LSB nanoseconds (50-500)</div>
</div>
<div class="form-group">
<label for="limit_refresh_rate_hz">Limit Refresh Rate (Hz):</label>
<input type="number" class="form-control" id="limit_refresh_rate_hz" name="limit_refresh_rate_hz" value="{{ main_config.display.hardware.limit_refresh_rate_hz }}" min="1" max="1000">
<input type="number" class="form-control" id="limit_refresh_rate_hz" name="limit_refresh_rate_hz" value="{{ safe_config_get(main_config, 'display', 'hardware', 'limit_refresh_rate_hz', default=120) }}" min="1" max="1000">
<div class="description">Limit refresh rate in Hz (1-1000)</div>
</div>
</div>
@@ -1059,28 +1063,28 @@
<div class="form-row">
<div class="form-group">
<label>
<input type="checkbox" id="disable_hardware_pulsing" name="disable_hardware_pulsing" {% if main_config.display.hardware.disable_hardware_pulsing %}checked{% endif %}>
<input type="checkbox" id="disable_hardware_pulsing" name="disable_hardware_pulsing" {% if safe_config_get(main_config, 'display', 'hardware', 'disable_hardware_pulsing', default=False) %}checked{% endif %}>
Disable Hardware Pulsing
</label>
<div class="description">Disable hardware pulsing</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="inverse_colors" name="inverse_colors" {% if main_config.display.hardware.inverse_colors %}checked{% endif %}>
<input type="checkbox" id="inverse_colors" name="inverse_colors" {% if safe_config_get(main_config, 'display', 'hardware', 'inverse_colors', default=False) %}checked{% endif %}>
Inverse Colors
</label>
<div class="description">Inverse color display</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="show_refresh_rate" name="show_refresh_rate" {% if main_config.display.hardware.show_refresh_rate %}checked{% endif %}>
<input type="checkbox" id="show_refresh_rate" name="show_refresh_rate" {% if safe_config_get(main_config, 'display', 'hardware', 'show_refresh_rate', default=False) %}checked{% endif %}>
Show Refresh Rate
</label>
<div class="description">Show refresh rate on display</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="use_short_date_format" name="use_short_date_format" {% if main_config.display.use_short_date_format %}checked{% endif %}>
<input type="checkbox" id="use_short_date_format" name="use_short_date_format" {% if safe_config_get(main_config, 'display', 'use_short_date_format', default=True) %}checked{% endif %}>
Use Short Date Format
</label>
<div class="description">Use short date format for display</div>
@@ -1099,18 +1103,18 @@
<form id="clock-form">
<div class="form-group">
<label>
<input type="checkbox" id="clock_enabled" {% if main_config.clock.enabled %}checked{% endif %}>
<input type="checkbox" id="clock_enabled" {% if safe_config_get(main_config, 'clock', 'enabled', default=True) %}checked{% endif %}>
Enable Clock
</label>
</div>
<div class="form-group">
<label for="clock_format">Format</label>
<input type="text" id="clock_format" class="form-control" value="{{ main_config.clock.format }}">
<input type="text" id="clock_format" class="form-control" value="{{ safe_config_get(main_config, 'clock', 'format', default='%I:%M %p') }}">
<div class="description">Python strftime format. Example: %I:%M %p for 12-hour time.</div>
</div>
<div class="form-group">
<label for="clock_update_interval">Update Interval (seconds)</label>
<input type="number" id="clock_update_interval" class="form-control" value="{{ main_config.clock.update_interval }}" min="1" max="60">
<input type="number" id="clock_update_interval" class="form-control" value="{{ safe_config_get(main_config, 'clock', 'update_interval', default=1) }}" min="1" max="60">
</div>
<button type="submit" class="btn btn-success">Save Clock Settings</button>
</form>
@@ -1124,7 +1128,7 @@
<p class="description">How long each screen is shown before switching. Values in seconds.</p>
<form id="durations-form">
<div class="form-row">
{% for key, value in main_config.display.display_durations.items() %}
{% for key, value in main_config.get('display', {}).get('display_durations', {}).items() %}
<div class="form-group">
<label for="duration_{{ key }}">{{ key | replace('_', ' ') | title }}</label>
<input type="number" class="form-control duration-input" id="duration_{{ key }}" data-name="{{ key }}" value="{{ value }}" min="5" max="600">
@@ -1166,36 +1170,36 @@
<form id="weather-form">
<div class="form-group">
<label>
<input type="checkbox" id="weather_enabled" name="weather_enabled" {% if main_config.weather.enabled %}checked{% endif %}>
<input type="checkbox" id="weather_enabled" name="weather_enabled" {% if safe_config_get(main_config, 'weather', 'enabled', default=False) %}checked{% endif %}>
Enable Weather
</label>
</div>
<div class="form-group">
<label for="weather_city">City:</label>
<input type="text" class="form-control" id="weather_city" name="weather_city" value="{{ main_config.location.city }}">
<input type="text" class="form-control" id="weather_city" name="weather_city" value="{{ safe_config_get(main_config, 'location', 'city', default='Dallas') }}">
<div class="description">City name for weather data</div>
</div>
<div class="form-group">
<label for="weather_state">State:</label>
<input type="text" class="form-control" id="weather_state" name="weather_state" value="{{ main_config.location.state }}">
<input type="text" class="form-control" id="weather_state" name="weather_state" value="{{ safe_config_get(main_config, 'location', 'state', default='Texas') }}">
<div class="description">State/province name</div>
</div>
<div class="form-group">
<label for="weather_units">Units:</label>
<select class="form-control" id="weather_units" name="weather_units">
<option value="imperial" {% if main_config.weather.units == "imperial" %}selected{% endif %}>Fahrenheit</option>
<option value="metric" {% if main_config.weather.units == "metric" %}selected{% endif %}>Celsius</option>
<option value="imperial" {% if safe_config_get(main_config, 'weather', 'units', default='imperial') == "imperial" %}selected{% endif %}>Fahrenheit</option>
<option value="metric" {% if safe_config_get(main_config, 'weather', 'units', default='imperial') == "metric" %}selected{% endif %}>Celsius</option>
</select>
<div class="description">Temperature units</div>
</div>
<div class="form-group">
<label for="weather_display_format">Display Format</label>
<textarea id="weather_display_format" class="form-control" rows="2">{{ main_config.weather.display_format }}</textarea>
<textarea id="weather_display_format" class="form-control" rows="2">{{ safe_config_get(main_config, 'weather', 'display_format', default='{temp}°F\n{condition}') }}</textarea>
<div class="description">Use tokens like {temp}, {condition}. Supports new lines.</div>
</div>
<div class="form-group">
<label for="weather_update_interval">Update Interval (seconds):</label>
<input type="number" class="form-control" id="weather_update_interval" name="weather_update_interval" value="{{ main_config.weather.update_interval }}" min="300" max="3600">
<input type="number" class="form-control" id="weather_update_interval" name="weather_update_interval" value="{{ safe_config_get(main_config, 'weather', 'update_interval', default=1800) }}" min="300" max="3600">
<div class="description">How often to update weather data (300-3600 seconds)</div>
</div>
<button type="submit" class="btn btn-success">Save Weather Settings</button>
@@ -1215,42 +1219,42 @@
<form id="stocks-form">
<div class="form-group">
<label>
<input type="checkbox" id="stocks_enabled" name="stocks_enabled" {% if main_config.stocks.enabled %}checked{% endif %}>
<input type="checkbox" id="stocks_enabled" name="stocks_enabled" {% if safe_config_get(main_config, 'stocks', 'enabled', default=False) %}checked{% endif %}>
Enable Stocks
</label>
</div>
<div class="form-group">
<label for="stocks_symbols">Stock Symbols:</label>
<input type="text" class="form-control" id="stocks_symbols" name="stocks_symbols" value="{{ main_config.stocks.symbols|join(', ') }}" placeholder="AAPL, GOOGL, MSFT">
<input type="text" class="form-control" id="stocks_symbols" name="stocks_symbols" value="{{ safe_config_get(main_config, 'stocks', 'symbols', default=['ASTS', 'SCHD', 'INTC', 'NVDA', 'T', 'VOO', 'SMCI'])|join(', ') }}" placeholder="AAPL, GOOGL, MSFT">
<div class="description">Comma-separated stock symbols</div>
</div>
<div class="form-group">
<label for="stocks_update_interval">Update Interval (seconds):</label>
<input type="number" class="form-control" id="stocks_update_interval" name="stocks_update_interval" value="{{ main_config.stocks.update_interval }}" min="60" max="3600">
<input type="number" class="form-control" id="stocks_update_interval" name="stocks_update_interval" value="{{ safe_config_get(main_config, 'stocks', 'update_interval', default=600) }}" min="60" max="3600">
<div class="description">How often to update stock data</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="stocks_scroll_speed">Scroll Speed</label>
<input type="number" step="0.1" class="form-control" id="stocks_scroll_speed" value="{{ main_config.stocks.scroll_speed }}">
<input type="number" step="0.1" class="form-control" id="stocks_scroll_speed" value="{{ safe_config_get(main_config, 'stocks', 'scroll_speed', default=1) }}">
<div class="description">Horizontal scroll pixels per step.</div>
</div>
<div class="form-group">
<label for="stocks_scroll_delay">Scroll Delay (seconds)</label>
<input type="number" step="0.001" class="form-control" id="stocks_scroll_delay" value="{{ main_config.stocks.scroll_delay }}">
<input type="number" step="0.001" class="form-control" id="stocks_scroll_delay" value="{{ safe_config_get(main_config, 'stocks', 'scroll_delay', default=0.01) }}">
<div class="description">Delay between scroll steps.</div>
</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="stocks_toggle_chart" name="stocks_toggle_chart" {% if main_config.stocks.toggle_chart %}checked{% endif %}>
<input type="checkbox" id="stocks_toggle_chart" name="stocks_toggle_chart" {% if safe_config_get(main_config, 'stocks', 'toggle_chart', default=True) %}checked{% endif %}>
Show Charts
</label>
<div class="description">Display mini charts alongside stock ticker data</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="stocks_dynamic_duration" {% if main_config.stocks.dynamic_duration %}checked{% endif %}>
<input type="checkbox" id="stocks_dynamic_duration" {% if safe_config_get(main_config, 'stocks', 'dynamic_duration', default=True) %}checked{% endif %}>
Dynamic Duration
</label>
<div class="description">Adjust display duration based on content length.</div>
@@ -1258,20 +1262,20 @@
<div class="form-row">
<div class="form-group">
<label for="stocks_min_duration">Min Duration (sec)</label>
<input type="number" class="form-control" id="stocks_min_duration" value="{{ main_config.stocks.min_duration }}">
<input type="number" class="form-control" id="stocks_min_duration" value="{{ safe_config_get(main_config, 'stocks', 'min_duration', default=30) }}">
</div>
<div class="form-group">
<label for="stocks_max_duration">Max Duration (sec)</label>
<input type="number" class="form-control" id="stocks_max_duration" value="{{ main_config.stocks.max_duration }}">
<input type="number" class="form-control" id="stocks_max_duration" value="{{ safe_config_get(main_config, 'stocks', 'max_duration', default=300) }}">
</div>
<div class="form-group">
<label for="stocks_duration_buffer">Duration Buffer</label>
<input type="number" step="0.01" class="form-control" id="stocks_duration_buffer" value="{{ main_config.stocks.duration_buffer }}">
<input type="number" step="0.01" class="form-control" id="stocks_duration_buffer" value="{{ safe_config_get(main_config, 'stocks', 'duration_buffer', default=0.1) }}">
</div>
</div>
<div class="form-group">
<label for="stocks_display_format">Display Format</label>
<input type="text" class="form-control" id="stocks_display_format" value="{{ main_config.stocks.display_format }}">
<input type="text" class="form-control" id="stocks_display_format" value="{{ safe_config_get(main_config, 'stocks', 'display_format', default='{symbol}: ${price} ({change}%)') }}">
<div class="description">Use tokens like {symbol}, {price}, {change}.</div>
</div>
<button type="submit" class="btn btn-success">Save Stocks Settings</button>
@@ -1281,18 +1285,18 @@
<form id="crypto-form">
<div class="form-group">
<label>
<input type="checkbox" id="crypto_enabled" name="crypto_enabled" {% if main_config.crypto.enabled %}checked{% endif %}>
<input type="checkbox" id="crypto_enabled" name="crypto_enabled" {% if safe_config_get(main_config, 'crypto', 'enabled', default=False) %}checked{% endif %}>
Enable Crypto
</label>
</div>
<div class="form-group">
<label for="crypto_symbols">Crypto Symbols:</label>
<input type="text" class="form-control" id="crypto_symbols" name="crypto_symbols" value="{{ main_config.crypto.symbols|join(', ') }}" placeholder="BTC-USD, ETH-USD">
<input type="text" class="form-control" id="crypto_symbols" name="crypto_symbols" value="{{ safe_config_get(main_config, 'crypto', 'symbols', default=['BTC-USD', 'ETH-USD'])|join(', ') }}" placeholder="BTC-USD, ETH-USD">
<div class="description">Comma-separated crypto symbols (e.g., BTC-USD, ETH-USD)</div>
</div>
<div class="form-group">
<label for="crypto_update_interval">Update Interval (seconds):</label>
<input type="number" class="form-control" id="crypto_update_interval" name="crypto_update_interval" value="{{ main_config.crypto.update_interval }}" min="60" max="3600">
<input type="number" class="form-control" id="crypto_update_interval" name="crypto_update_interval" value="{{ safe_config_get(main_config, 'crypto', 'update_interval', default=600) }}" min="60" max="3600">
<div class="description">How often to update crypto data</div>
</div>
<button type="submit" class="btn btn-success">Save Crypto Settings</button>
@@ -1312,44 +1316,44 @@
<form id="stocknews-form">
<div class="form-group">
<label>
<input type="checkbox" id="stocknews_enabled" {% if main_config.stock_news.enabled %}checked{% endif %}>
<input type="checkbox" id="stocknews_enabled" {% if safe_config_get(main_config, 'stock_news', 'enabled', default=False) %}checked{% endif %}>
Enable Stock News
</label>
</div>
<div class="form-row">
<div class="form-group">
<label for="stocknews_update_interval">Update Interval (sec)</label>
<input type="number" class="form-control" id="stocknews_update_interval" value="{{ main_config.stock_news.update_interval }}">
<input type="number" class="form-control" id="stocknews_update_interval" value="{{ safe_config_get(main_config, 'stock_news', 'update_interval', default=3600) }}">
</div>
<div class="form-group">
<label for="stocknews_scroll_speed">Scroll Speed</label>
<input type="number" step="0.1" class="form-control" id="stocknews_scroll_speed" value="{{ main_config.stock_news.scroll_speed }}">
<input type="number" step="0.1" class="form-control" id="stocknews_scroll_speed" value="{{ safe_config_get(main_config, 'stock_news', 'scroll_speed', default=1) }}">
</div>
<div class="form-group">
<label for="stocknews_scroll_delay">Scroll Delay (sec)</label>
<input type="number" step="0.001" class="form-control" id="stocknews_scroll_delay" value="{{ main_config.stock_news.scroll_delay }}">
<input type="number" step="0.001" class="form-control" id="stocknews_scroll_delay" value="{{ safe_config_get(main_config, 'stock_news', 'scroll_delay', default=0.01) }}">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="stocknews_max_headlines_per_symbol">Max Headlines per Symbol</label>
<input type="number" class="form-control" id="stocknews_max_headlines_per_symbol" value="{{ main_config.stock_news.max_headlines_per_symbol }}">
<input type="number" class="form-control" id="stocknews_max_headlines_per_symbol" value="{{ safe_config_get(main_config, 'stock_news', 'max_headlines_per_symbol', default=1) }}">
</div>
<div class="form-group">
<label for="stocknews_headlines_per_rotation">Headlines per Rotation</label>
<input type="number" class="form-control" id="stocknews_headlines_per_rotation" value="{{ main_config.stock_news.headlines_per_rotation }}">
<input type="number" class="form-control" id="stocknews_headlines_per_rotation" value="{{ safe_config_get(main_config, 'stock_news', 'headlines_per_rotation', default=2) }}">
</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="stocknews_dynamic_duration" {% if main_config.stock_news.dynamic_duration %}checked{% endif %}>
<input type="checkbox" id="stocknews_dynamic_duration" {% if safe_config_get(main_config, 'stock_news', 'dynamic_duration', default=True) %}checked{% endif %}>
Dynamic Duration
</label>
</div>
<div class="form-row">
<div class="form-group"><label for="stocknews_min_duration">Min Duration</label><input type="number" class="form-control" id="stocknews_min_duration" value="{{ main_config.stock_news.min_duration }}"></div>
<div class="form-group"><label for="stocknews_max_duration">Max Duration</label><input type="number" class="form-control" id="stocknews_max_duration" value="{{ main_config.stock_news.max_duration }}"></div>
<div class="form-group"><label for="stocknews_duration_buffer">Duration Buffer</label><input type="number" step="0.01" class="form-control" id="stocknews_duration_buffer" value="{{ main_config.stock_news.duration_buffer }}"></div>
<div class="form-group"><label for="stocknews_min_duration">Min Duration</label><input type="number" class="form-control" id="stocknews_min_duration" value="{{ safe_config_get(main_config, 'stock_news', 'min_duration', default=30) }}"></div>
<div class="form-group"><label for="stocknews_max_duration">Max Duration</label><input type="number" class="form-control" id="stocknews_max_duration" value="{{ safe_config_get(main_config, 'stock_news', 'max_duration', default=300) }}"></div>
<div class="form-group"><label for="stocknews_duration_buffer">Duration Buffer</label><input type="number" step="0.01" class="form-control" id="stocknews_duration_buffer" value="{{ safe_config_get(main_config, 'stock_news', 'duration_buffer', default=0.1) }}"></div>
</div>
<button type="submit" class="btn btn-success">Save Stock News</button>
</form>
@@ -1369,45 +1373,45 @@
<form id="odds-form">
<div class="form-group">
<label>
<input type="checkbox" id="odds_enabled" {% if main_config.odds_ticker.enabled %}checked{% endif %}>
<input type="checkbox" id="odds_enabled" {% if safe_config_get(main_config, 'odds_ticker', 'enabled', default=True) %}checked{% endif %}>
Enable Odds Ticker
</label>
</div>
<div class="form-row">
<div class="form-group"><label for="odds_update_interval">Update Interval (sec)</label><input type="number" class="form-control" id="odds_update_interval" value="{{ main_config.odds_ticker.update_interval }}"></div>
<div class="form-group"><label for="odds_scroll_speed">Scroll Speed</label><input type="number" step="0.1" class="form-control" id="odds_scroll_speed" value="{{ main_config.odds_ticker.scroll_speed }}"></div>
<div class="form-group"><label for="odds_scroll_delay">Scroll Delay (sec)</label><input type="number" step="0.001" class="form-control" id="odds_scroll_delay" value="{{ main_config.odds_ticker.scroll_delay }}"></div>
<div class="form-group"><label for="odds_update_interval">Update Interval (sec)</label><input type="number" class="form-control" id="odds_update_interval" value="{{ safe_config_get(main_config, 'odds_ticker', 'update_interval', default=3600) }}"></div>
<div class="form-group"><label for="odds_scroll_speed">Scroll Speed</label><input type="number" step="0.1" class="form-control" id="odds_scroll_speed" value="{{ safe_config_get(main_config, 'odds_ticker', 'scroll_speed', default=1) }}"></div>
<div class="form-group"><label for="odds_scroll_delay">Scroll Delay (sec)</label><input type="number" step="0.001" class="form-control" id="odds_scroll_delay" value="{{ safe_config_get(main_config, 'odds_ticker', 'scroll_delay', default=0.01) }}"></div>
</div>
<div class="form-row">
<div class="form-group"><label for="odds_games_per_favorite_team">Games per Favorite Team</label><input type="number" class="form-control" id="odds_games_per_favorite_team" value="{{ main_config.odds_ticker.games_per_favorite_team }}"></div>
<div class="form-group"><label for="odds_max_games_per_league">Max Games per League</label><input type="number" class="form-control" id="odds_max_games_per_league" value="{{ main_config.odds_ticker.max_games_per_league }}"></div>
<div class="form-group"><label for="odds_future_fetch_days">Future Fetch Days</label><input type="number" class="form-control" id="odds_future_fetch_days" value="{{ main_config.odds_ticker.future_fetch_days }}"></div>
<div class="form-group"><label for="odds_games_per_favorite_team">Games per Favorite Team</label><input type="number" class="form-control" id="odds_games_per_favorite_team" value="{{ safe_config_get(main_config, 'odds_ticker', 'games_per_favorite_team', default=1) }}"></div>
<div class="form-group"><label for="odds_max_games_per_league">Max Games per League</label><input type="number" class="form-control" id="odds_max_games_per_league" value="{{ safe_config_get(main_config, 'odds_ticker', 'max_games_per_league', default=5) }}"></div>
<div class="form-group"><label for="odds_future_fetch_days">Future Fetch Days</label><input type="number" class="form-control" id="odds_future_fetch_days" value="{{ safe_config_get(main_config, 'odds_ticker', 'future_fetch_days', default=50) }}"></div>
</div>
<div class="form-row">
<div class="form-group">
<label for="odds_enabled_leagues">Enabled Leagues</label>
<input type="text" class="form-control" id="odds_enabled_leagues" value="{{ main_config.odds_ticker.enabled_leagues | join(', ') }}">
<input type="text" class="form-control" id="odds_enabled_leagues" value="{{ safe_config_get(main_config, 'odds_ticker', 'enabled_leagues', default=['nfl', 'mlb', 'ncaa_fb', 'milb']) | join(', ') }}">
<div class="description">Comma-separated list, e.g., nfl, mlb, ncaa_fb, milb</div>
</div>
<div class="form-group">
<label for="odds_sort_order">Sort Order</label>
<select id="odds_sort_order" class="form-control">
<option value="soonest" {% if main_config.odds_ticker.sort_order == 'soonest' %}selected{% endif %}>Soonest</option>
<option value="league" {% if main_config.odds_ticker.sort_order == 'league' %}selected{% endif %}>By League</option>
<option value="soonest" {% if safe_config_get(main_config, 'odds_ticker', 'sort_order', default='soonest') == 'soonest' %}selected{% endif %}>Soonest</option>
<option value="league" {% if safe_config_get(main_config, 'odds_ticker', 'sort_order', default='soonest') == 'league' %}selected{% endif %}>By League</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group"><label><input type="checkbox" id="odds_show_favorite_teams_only" {% if main_config.odds_ticker.show_favorite_teams_only %}checked{% endif %}> Show Favorite Teams Only</label></div>
<div class="form-group"><label><input type="checkbox" id="odds_show_odds_only" {% if main_config.odds_ticker.show_odds_only %}checked{% endif %}> Show Odds Only</label></div>
<div class="form-group"><label><input type="checkbox" id="odds_loop" {% if main_config.odds_ticker.loop %}checked{% endif %}> Loop</label></div>
<div class="form-group"><label><input type="checkbox" id="odds_show_channel_logos" {% if main_config.odds_ticker.show_channel_logos %}checked{% endif %}> Show Channel Logos</label></div>
<div class="form-group"><label><input type="checkbox" id="odds_show_favorite_teams_only" {% if safe_config_get(main_config, 'odds_ticker', 'show_favorite_teams_only', default=True) %}checked{% endif %}> Show Favorite Teams Only</label></div>
<div class="form-group"><label><input type="checkbox" id="odds_show_odds_only" {% if safe_config_get(main_config, 'odds_ticker', 'show_odds_only', default=False) %}checked{% endif %}> Show Odds Only</label></div>
<div class="form-group"><label><input type="checkbox" id="odds_loop" {% if safe_config_get(main_config, 'odds_ticker', 'loop', default=True) %}checked{% endif %}> Loop</label></div>
<div class="form-group"><label><input type="checkbox" id="odds_show_channel_logos" {% if safe_config_get(main_config, 'odds_ticker', 'show_channel_logos', default=True) %}checked{% endif %}> Show Channel Logos</label></div>
</div>
<div class="form-group"><label><input type="checkbox" id="odds_dynamic_duration" {% if main_config.odds_ticker.dynamic_duration %}checked{% endif %}> Dynamic Duration</label></div>
<div class="form-group"><label><input type="checkbox" id="odds_dynamic_duration" {% if safe_config_get(main_config, 'odds_ticker', 'dynamic_duration', default=True) %}checked{% endif %}> Dynamic Duration</label></div>
<div class="form-row">
<div class="form-group"><label for="odds_min_duration">Min Duration</label><input type="number" class="form-control" id="odds_min_duration" value="{{ main_config.odds_ticker.min_duration }}"></div>
<div class="form-group"><label for="odds_max_duration">Max Duration</label><input type="number" class="form-control" id="odds_max_duration" value="{{ main_config.odds_ticker.max_duration }}"></div>
<div class="form-group"><label for="odds_duration_buffer">Duration Buffer</label><input type="number" step="0.01" class="form-control" id="odds_duration_buffer" value="{{ main_config.odds_ticker.duration_buffer }}"></div>
<div class="form-group"><label for="odds_min_duration">Min Duration</label><input type="number" class="form-control" id="odds_min_duration" value="{{ safe_config_get(main_config, 'odds_ticker', 'min_duration', default=30) }}"></div>
<div class="form-group"><label for="odds_max_duration">Max Duration</label><input type="number" class="form-control" id="odds_max_duration" value="{{ safe_config_get(main_config, 'odds_ticker', 'max_duration', default=300) }}"></div>
<div class="form-group"><label for="odds_duration_buffer">Duration Buffer</label><input type="number" step="0.01" class="form-control" id="odds_duration_buffer" value="{{ safe_config_get(main_config, 'odds_ticker', 'duration_buffer', default=0.1) }}"></div>
</div>
<button type="submit" class="btn btn-success">Save Odds Settings</button>
</form>
@@ -1427,126 +1431,126 @@
<form id="leaderboard-form">
<div class="form-group">
<label>
<input type="checkbox" id="leaderboard_enabled" {% if main_config.leaderboard.enabled %}checked{% endif %}>
<input type="checkbox" id="leaderboard_enabled" {% if safe_config_get(main_config, 'leaderboard', 'enabled', default=False) %}checked{% endif %}>
Enable Leaderboard
</label>
</div>
<div class="form-row">
<div class="form-group">
<label for="leaderboard_update_interval">Update Interval (sec)</label>
<input type="number" class="form-control" id="leaderboard_update_interval" value="{{ main_config.leaderboard.update_interval }}">
<input type="number" class="form-control" id="leaderboard_update_interval" value="{{ safe_config_get(main_config, 'leaderboard', 'update_interval', default=3600) }}">
</div>
<div class="form-group">
<label for="leaderboard_scroll_speed">Scroll Speed</label>
<input type="number" step="0.1" class="form-control" id="leaderboard_scroll_speed" value="{{ main_config.leaderboard.scroll_speed }}">
<input type="number" step="0.1" class="form-control" id="leaderboard_scroll_speed" value="{{ safe_config_get(main_config, 'leaderboard', 'scroll_speed', default=1) }}">
</div>
<div class="form-group">
<label for="leaderboard_scroll_delay">Scroll Delay (sec)</label>
<input type="number" step="0.001" class="form-control" id="leaderboard_scroll_delay" value="{{ main_config.leaderboard.scroll_delay }}">
<input type="number" step="0.001" class="form-control" id="leaderboard_scroll_delay" value="{{ safe_config_get(main_config, 'leaderboard', 'scroll_delay', default=0.01) }}">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="leaderboard_display_duration">Display Duration (sec)</label>
<input type="number" class="form-control" id="leaderboard_display_duration" value="{{ main_config.leaderboard.display_duration }}">
<input type="number" class="form-control" id="leaderboard_display_duration" value="{{ safe_config_get(main_config, 'leaderboard', 'display_duration', default=30) }}">
</div>
<div class="form-group">
<label for="leaderboard_request_timeout">Request Timeout (sec)</label>
<input type="number" class="form-control" id="leaderboard_request_timeout" value="{{ main_config.leaderboard.request_timeout }}">
<input type="number" class="form-control" id="leaderboard_request_timeout" value="{{ safe_config_get(main_config, 'leaderboard', 'request_timeout', default=10) }}">
</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="leaderboard_loop" {% if main_config.leaderboard.loop %}checked{% endif %}>
<input type="checkbox" id="leaderboard_loop" {% if safe_config_get(main_config, 'leaderboard', 'loop', default=True) %}checked{% endif %}>
Loop
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="leaderboard_dynamic_duration" {% if main_config.leaderboard.dynamic_duration %}checked{% endif %}>
<input type="checkbox" id="leaderboard_dynamic_duration" {% if safe_config_get(main_config, 'leaderboard', 'dynamic_duration', default=True) %}checked{% endif %}>
Dynamic Duration
</label>
</div>
<div class="form-row">
<div class="form-group">
<label for="leaderboard_min_duration">Min Duration (sec)</label>
<input type="number" class="form-control" id="leaderboard_min_duration" value="{{ main_config.leaderboard.min_duration }}">
<input type="number" class="form-control" id="leaderboard_min_duration" value="{{ safe_config_get(main_config, 'leaderboard', 'min_duration', default=30) }}">
</div>
<div class="form-group">
<label for="leaderboard_max_duration">Max Duration (sec)</label>
<input type="number" class="form-control" id="leaderboard_max_duration" value="{{ main_config.leaderboard.max_duration }}">
<input type="number" class="form-control" id="leaderboard_max_duration" value="{{ safe_config_get(main_config, 'leaderboard', 'max_duration', default=300) }}">
</div>
<div class="form-group">
<label for="leaderboard_duration_buffer">Duration Buffer</label>
<input type="number" step="0.01" class="form-control" id="leaderboard_duration_buffer" value="{{ main_config.leaderboard.duration_buffer }}">
<input type="number" step="0.01" class="form-control" id="leaderboard_duration_buffer" value="{{ safe_config_get(main_config, 'leaderboard', 'duration_buffer', default=0.1) }}">
</div>
</div>
<h4>Enabled Sports</h4>
<div class="form-group">
<label>
<input type="checkbox" id="leaderboard_nfl_enabled" {% if main_config.leaderboard.enabled_sports.nfl.enabled %}checked{% endif %}>
<input type="checkbox" id="leaderboard_nfl_enabled" {% if safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'nfl', 'enabled', default=False) %}checked{% endif %}>
NFL
</label>
<div class="form-group" style="margin-left: 20px;">
<label for="leaderboard_nfl_top_teams">Top Teams</label>
<input type="number" class="form-control" id="leaderboard_nfl_top_teams" value="{{ main_config.leaderboard.enabled_sports.nfl.top_teams }}" min="1" max="32">
<input type="number" class="form-control" id="leaderboard_nfl_top_teams" value="{{ safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'nfl', 'top_teams', default=10) }}" min="1" max="32">
</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="leaderboard_nba_enabled" {% if main_config.leaderboard.enabled_sports.nba.enabled %}checked{% endif %}>
<input type="checkbox" id="leaderboard_nba_enabled" {% if safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'nba', 'enabled', default=False) %}checked{% endif %}>
NBA
</label>
<div class="form-group" style="margin-left: 20px;">
<label for="leaderboard_nba_top_teams">Top Teams</label>
<input type="number" class="form-control" id="leaderboard_nba_top_teams" value="{{ main_config.leaderboard.enabled_sports.nba.top_teams }}" min="1" max="30">
<input type="number" class="form-control" id="leaderboard_nba_top_teams" value="{{ safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'nba', 'top_teams', default=10) }}" min="1" max="30">
</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="leaderboard_mlb_enabled" {% if main_config.leaderboard.enabled_sports.mlb.enabled %}checked{% endif %}>
<input type="checkbox" id="leaderboard_mlb_enabled" {% if safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'mlb', 'enabled', default=False) %}checked{% endif %}>
MLB
</label>
<div class="form-group" style="margin-left: 20px;">
<label for="leaderboard_mlb_top_teams">Top Teams</label>
<input type="number" class="form-control" id="leaderboard_mlb_top_teams" value="{{ main_config.leaderboard.enabled_sports.mlb.top_teams }}" min="1" max="30">
<input type="number" class="form-control" id="leaderboard_mlb_top_teams" value="{{ safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'mlb', 'top_teams', default=10) }}" min="1" max="30">
</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="leaderboard_ncaa_fb_enabled" {% if main_config.leaderboard.enabled_sports.ncaa_fb.enabled %}checked{% endif %}>
<input type="checkbox" id="leaderboard_ncaa_fb_enabled" {% if safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'ncaa_fb', 'enabled', default=False) %}checked{% endif %}>
NCAA Football
</label>
<div class="form-group" style="margin-left: 20px;">
<label for="leaderboard_ncaa_fb_top_teams">Top Teams</label>
<input type="number" class="form-control" id="leaderboard_ncaa_fb_top_teams" value="{{ main_config.leaderboard.enabled_sports.ncaa_fb.top_teams }}" min="1" max="25">
<input type="number" class="form-control" id="leaderboard_ncaa_fb_top_teams" value="{{ safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'ncaa_fb', 'top_teams', default=10) }}" min="1" max="25">
</div>
<div class="form-group" style="margin-left: 20px;">
<label>
<input type="checkbox" id="leaderboard_ncaa_fb_show_ranking" {% if main_config.leaderboard.enabled_sports.ncaa_fb.show_ranking %}checked{% endif %}>
<input type="checkbox" id="leaderboard_ncaa_fb_show_ranking" {% if safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'ncaa_fb', 'show_ranking', default=True) %}checked{% endif %}>
Show Ranking
</label>
</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="leaderboard_nhl_enabled" {% if main_config.leaderboard.enabled_sports.nhl.enabled %}checked{% endif %}>
<input type="checkbox" id="leaderboard_nhl_enabled" {% if safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'nhl', 'enabled', default=False) %}checked{% endif %}>
NHL
</label>
<div class="form-group" style="margin-left: 20px;">
<label for="leaderboard_nhl_top_teams">Top Teams</label>
<input type="number" class="form-control" id="leaderboard_nhl_top_teams" value="{{ main_config.leaderboard.enabled_sports.nhl.top_teams }}" min="1" max="32">
<input type="number" class="form-control" id="leaderboard_nhl_top_teams" value="{{ safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'nhl', 'top_teams', default=10) }}" min="1" max="32">
</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="leaderboard_ncaam_basketball_enabled" {% if main_config.leaderboard.enabled_sports.ncaam_basketball.enabled %}checked{% endif %}>
<input type="checkbox" id="leaderboard_ncaam_basketball_enabled" {% if safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'ncaam_basketball', 'enabled', default=False) %}checked{% endif %}>
NCAA Men's Basketball
</label>
<div class="form-group" style="margin-left: 20px;">
<label for="leaderboard_ncaam_basketball_top_teams">Top Teams</label>
<input type="number" class="form-control" id="leaderboard_ncaam_basketball_top_teams" value="{{ main_config.leaderboard.enabled_sports.ncaam_basketball.top_teams }}" min="1" max="25">
<input type="number" class="form-control" id="leaderboard_ncaam_basketball_top_teams" value="{{ safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'ncaam_basketball', 'top_teams', default=10) }}" min="1" max="25">
</div>
</div>
@@ -1565,18 +1569,18 @@
</div>
</div>
<form id="text-form">
<div class="form-group"><label><input type="checkbox" id="text_enabled" {% if main_config.text_display.enabled %}checked{% endif %}> Enable</label></div>
<div class="form-group"><label for="text_text">Text</label><input type="text" id="text_text" class="form-control" value="{{ main_config.text_display.text }}"></div>
<div class="form-group"><label for="text_font_path">Font Path</label><input type="text" id="text_font_path" class="form-control" value="{{ main_config.text_display.font_path }}"></div>
<div class="form-group"><label><input type="checkbox" id="text_enabled" {% if safe_config_get(main_config, 'text_display', 'enabled', default=False) %}checked{% endif %}> Enable</label></div>
<div class="form-group"><label for="text_text">Text</label><input type="text" id="text_text" class="form-control" value="{{ safe_config_get(main_config, 'text_display', 'text', default='Subscribe to ChuckBuilds') }}"></div>
<div class="form-group"><label for="text_font_path">Font Path</label><input type="text" id="text_font_path" class="form-control" value="{{ safe_config_get(main_config, 'text_display', 'font_path', default='assets/fonts/press-start-2p.ttf') }}"></div>
<div class="form-row">
<div class="form-group"><label for="text_font_size">Font Size</label><input type="number" id="text_font_size" class="form-control" value="{{ main_config.text_display.font_size }}"></div>
<div class="form-group"><label><input type="checkbox" id="text_scroll" {% if main_config.text_display.scroll %}checked{% endif %}> Scroll</label></div>
<div class="form-group"><label for="text_scroll_speed">Scroll Speed</label><input type="number" id="text_scroll_speed" class="form-control" value="{{ main_config.text_display.scroll_speed }}"></div>
<div class="form-group"><label for="text_scroll_gap_width">Scroll Gap Width</label><input type="number" id="text_scroll_gap_width" class="form-control" value="{{ main_config.text_display.scroll_gap_width }}"></div>
<div class="form-group"><label for="text_font_size">Font Size</label><input type="number" id="text_font_size" class="form-control" value="{{ safe_config_get(main_config, 'text_display', 'font_size', default=8) }}"></div>
<div class="form-group"><label><input type="checkbox" id="text_scroll" {% if safe_config_get(main_config, 'text_display', 'scroll', default=True) %}checked{% endif %}> Scroll</label></div>
<div class="form-group"><label for="text_scroll_speed">Scroll Speed</label><input type="number" id="text_scroll_speed" class="form-control" value="{{ safe_config_get(main_config, 'text_display', 'scroll_speed', default=40) }}"></div>
<div class="form-group"><label for="text_scroll_gap_width">Scroll Gap Width</label><input type="number" id="text_scroll_gap_width" class="form-control" value="{{ safe_config_get(main_config, 'text_display', 'scroll_gap_width', default=32) }}"></div>
</div>
<div class="form-row">
<div class="form-group"><label for="text_text_color">Text Color</label><input type="color" id="text_text_color" class="form-control" data-rgb='{{ main_config.text_display.text_color | tojson }}'></div>
<div class="form-group"><label for="text_background_color">Background Color</label><input type="color" id="text_background_color" class="form-control" data-rgb='{{ main_config.text_display.background_color | tojson }}'></div>
<div class="form-group"><label for="text_text_color">Text Color</label><input type="color" id="text_text_color" class="form-control" data-rgb='{{ safe_config_get(main_config, 'text_display', 'text_color', default=[255, 0, 0]) | tojson }}'></div>
<div class="form-group"><label for="text_background_color">Background Color</label><input type="color" id="text_background_color" class="form-control" data-rgb='{{ safe_config_get(main_config, 'text_display', 'background_color', default=[0, 0, 0]) | tojson }}'></div>
</div>
<button type="submit" class="btn btn-success">Save Text Settings</button>
</form>
@@ -1607,27 +1611,27 @@
<form id="of_the_day-form">
<div class="form-group">
<label>
<input type="checkbox" id="of_the_day_enabled" {% if main_config.of_the_day.enabled %}checked{% endif %}>
<input type="checkbox" id="of_the_day_enabled" {% if safe_config_get(main_config, 'of_the_day', 'enabled', default=False) %}checked{% endif %}>
Enable Of The Day
</label>
</div>
<div class="form-row">
<div class="form-group">
<label for="of_the_day_update_interval">Update Interval (sec)</label>
<input type="number" class="form-control" id="of_the_day_update_interval" value="{{ main_config.of_the_day.update_interval }}">
<input type="number" class="form-control" id="of_the_day_update_interval" value="{{ safe_config_get(main_config, 'of_the_day', 'update_interval', default=3600) }}">
</div>
<div class="form-group">
<label for="of_the_day_display_rotate_interval">Display Rotate Interval (sec)</label>
<input type="number" class="form-control" id="of_the_day_display_rotate_interval" value="{{ main_config.of_the_day.display_rotate_interval }}">
<input type="number" class="form-control" id="of_the_day_display_rotate_interval" value="{{ safe_config_get(main_config, 'of_the_day', 'display_rotate_interval', default=20) }}">
</div>
<div class="form-group">
<label for="of_the_day_subtitle_rotate_interval">Subtitle Rotate Interval (sec)</label>
<input type="number" class="form-control" id="of_the_day_subtitle_rotate_interval" value="{{ main_config.of_the_day.subtitle_rotate_interval }}">
<input type="number" class="form-control" id="of_the_day_subtitle_rotate_interval" value="{{ safe_config_get(main_config, 'of_the_day', 'subtitle_rotate_interval', default=10) }}">
</div>
</div>
<div class="form-group">
<label for="of_the_day_category_order">Category Order</label>
<input type="text" class="form-control" id="of_the_day_category_order" value="{{ main_config.of_the_day.category_order | join(', ') }}" placeholder="word_of_the_day, slovenian_word_of_the_day">
<input type="text" class="form-control" id="of_the_day_category_order" value="{{ safe_config_get(main_config, 'of_the_day', 'category_order', default=['word_of_the_day', 'slovenian_word_of_the_day']) | join(', ') }}" placeholder="word_of_the_day, slovenian_word_of_the_day">
<div class="description">Comma-separated list of category keys in display order</div>
</div>
@@ -1636,17 +1640,17 @@
<h5>Word of the Day</h5>
<div class="form-group" style="margin-left: 20px;">
<label>
<input type="checkbox" id="of_the_day_word_enabled" {% if main_config.of_the_day.categories.word_of_the_day.enabled %}checked{% endif %}>
<input type="checkbox" id="of_the_day_word_enabled" {% if safe_config_get(main_config, 'of_the_day', 'categories', 'word_of_the_day', 'enabled', default=True) %}checked{% endif %}>
Enable Word of the Day
</label>
</div>
<div class="form-group" style="margin-left: 20px;">
<label for="of_the_day_word_data_file">Data File</label>
<input type="text" class="form-control" id="of_the_day_word_data_file" value="{{ main_config.of_the_day.categories.word_of_the_day.data_file }}">
<input type="text" class="form-control" id="of_the_day_word_data_file" value="{{ safe_config_get(main_config, 'of_the_day', 'categories', 'word_of_the_day', 'data_file', default='of_the_day/word_of_the_day.json') }}">
</div>
<div class="form-group" style="margin-left: 20px;">
<label for="of_the_day_word_display_name">Display Name</label>
<input type="text" class="form-control" id="of_the_day_word_display_name" value="{{ main_config.of_the_day.categories.word_of_the_day.display_name }}">
<input type="text" class="form-control" id="of_the_day_word_display_name" value="{{ safe_config_get(main_config, 'of_the_day', 'categories', 'word_of_the_day', 'display_name', default='Word of the Day') }}">
</div>
</div>
@@ -1654,17 +1658,17 @@
<h5>Slovenian Word of the Day</h5>
<div class="form-group" style="margin-left: 20px;">
<label>
<input type="checkbox" id="of_the_day_slovenian_enabled" {% if main_config.of_the_day.categories.slovenian_word_of_the_day.enabled %}checked{% endif %}>
<input type="checkbox" id="of_the_day_slovenian_enabled" {% if safe_config_get(main_config, 'of_the_day', 'categories', 'slovenian_word_of_the_day', 'enabled', default=True) %}checked{% endif %}>
Enable Slovenian Word of the Day
</label>
</div>
<div class="form-group" style="margin-left: 20px;">
<label for="of_the_day_slovenian_data_file">Data File</label>
<input type="text" class="form-control" id="of_the_day_slovenian_data_file" value="{{ main_config.of_the_day.categories.slovenian_word_of_the_day.data_file }}">
<input type="text" class="form-control" id="of_the_day_slovenian_data_file" value="{{ safe_config_get(main_config, 'of_the_day', 'categories', 'slovenian_word_of_the_day', 'data_file', default='of_the_day/slovenian_word_of_the_day.json') }}">
</div>
<div class="form-group" style="margin-left: 20px;">
<label for="of_the_day_slovenian_display_name">Display Name</label>
<input type="text" class="form-control" id="of_the_day_slovenian_display_name" value="{{ main_config.of_the_day.categories.slovenian_word_of_the_day.display_name }}">
<input type="text" class="form-control" id="of_the_day_slovenian_display_name" value="{{ safe_config_get(main_config, 'of_the_day', 'categories', 'slovenian_word_of_the_day', 'display_name', default='Slovenian Word of the Day') }}">
</div>
</div>
@@ -1680,26 +1684,26 @@
<form id="music-form">
<div class="form-group">
<label>
<input type="checkbox" id="music_enabled" name="music_enabled" {% if main_config.music.enabled %}checked{% endif %}>
<input type="checkbox" id="music_enabled" name="music_enabled" {% if safe_config_get(main_config, 'music', 'enabled', default=False) %}checked{% endif %}>
Enable Music Display
</label>
</div>
<div class="form-group">
<label for="music_preferred_source">Preferred Source:</label>
<select class="form-control" id="music_preferred_source" name="music_preferred_source">
<option value="ytm" {% if main_config.music.preferred_source == "ytm" %}selected{% endif %}>YouTube Music</option>
<option value="spotify" {% if main_config.music.preferred_source == "spotify" %}selected{% endif %}>Spotify</option>
<option value="ytm" {% if safe_config_get(main_config, 'music', 'preferred_source', default='ytm') == "ytm" %}selected{% endif %}>YouTube Music</option>
<option value="spotify" {% if safe_config_get(main_config, 'music', 'preferred_source', default='ytm') == "spotify" %}selected{% endif %}>Spotify</option>
</select>
<div class="description">Primary music source to display</div>
</div>
<div class="form-group">
<label for="ytm_companion_url">YouTube Music Companion URL:</label>
<input type="text" class="form-control" id="ytm_companion_url" name="ytm_companion_url" value="{{ main_config.music.YTM_COMPANION_URL }}">
<input type="text" class="form-control" id="ytm_companion_url" name="ytm_companion_url" value="{{ safe_config_get(main_config, 'music', 'YTM_COMPANION_URL', default='http://192.168.86.12:9863') }}">
<div class="description">URL for YouTube Music companion app</div>
</div>
<div class="form-group">
<label for="music_polling_interval">Polling Interval (seconds):</label>
<input type="number" class="form-control" id="music_polling_interval" name="music_polling_interval" value="{{ main_config.music.POLLING_INTERVAL_SECONDS }}" min="1" max="60">
<input type="number" class="form-control" id="music_polling_interval" name="music_polling_interval" value="{{ safe_config_get(main_config, 'music', 'POLLING_INTERVAL_SECONDS', default=1) }}" min="1" max="60">
<div class="description">How often to check for music updates</div>
</div>
<button type="submit" class="btn btn-success">Save Music Settings</button>
@@ -1717,8 +1721,8 @@
</div>
</div>
<form id="youtube-form">
<div class="form-group"><label><input type="checkbox" id="youtube_enabled" {% if main_config.youtube.enabled %}checked{% endif %}> Enable YouTube</label></div>
<div class="form-group"><label for="youtube_update_interval">Update Interval (sec)</label><input type="number" id="youtube_update_interval" class="form-control" value="{{ main_config.youtube.update_interval }}"></div>
<div class="form-group"><label><input type="checkbox" id="youtube_enabled" {% if safe_config_get(main_config, 'youtube', 'enabled', default=False) %}checked{% endif %}> Enable YouTube</label></div>
<div class="form-group"><label for="youtube_update_interval">Update Interval (sec)</label><input type="number" id="youtube_update_interval" class="form-control" value="{{ safe_config_get(main_config, 'youtube', 'update_interval', default=3600) }}"></div>
<button type="submit" class="btn btn-success">Save YouTube Settings</button>
</form>
</div>
@@ -1736,23 +1740,23 @@
<form id="calendar-form">
<div class="form-group">
<label>
<input type="checkbox" id="calendar_enabled" name="calendar_enabled" {% if main_config.calendar.enabled %}checked{% endif %}>
<input type="checkbox" id="calendar_enabled" name="calendar_enabled" {% if safe_config_get(main_config, 'calendar', 'enabled', default=False) %}checked{% endif %}>
Enable Calendar
</label>
</div>
<div class="form-group">
<label for="calendar_max_events">Max Events to Show:</label>
<input type="number" class="form-control" id="calendar_max_events" name="calendar_max_events" value="{{ main_config.calendar.max_events }}" min="1" max="10">
<input type="number" class="form-control" id="calendar_max_events" name="calendar_max_events" value="{{ safe_config_get(main_config, 'calendar', 'max_events', default=3) }}" min="1" max="10">
<div class="description">Maximum number of events to display</div>
</div>
<div class="form-group">
<label for="calendar_update_interval">Update Interval (seconds):</label>
<input type="number" class="form-control" id="calendar_update_interval" name="calendar_update_interval" value="{{ main_config.calendar.update_interval }}" min="300" max="3600">
<input type="number" class="form-control" id="calendar_update_interval" name="calendar_update_interval" value="{{ safe_config_get(main_config, 'calendar', 'update_interval', default=3600) }}" min="300" max="3600">
<div class="description">How often to update calendar data</div>
</div>
<div class="form-group">
<label for="calendar_calendars">Calendars:</label>
<input type="text" class="form-control" id="calendar_calendars" name="calendar_calendars" value="{{ main_config.calendar.calendars|join(', ') }}" placeholder="birthdays, work">
<input type="text" class="form-control" id="calendar_calendars" name="calendar_calendars" value="{{ safe_config_get(main_config, 'calendar', 'calendars', default=['birthdays'])|join(', ') }}" placeholder="birthdays, work">
<div class="description">Comma-separated calendar names</div>
</div>
<button type="submit" class="btn btn-success">Save Calendar Settings</button>
@@ -1822,24 +1826,24 @@
<h4 style="margin-top:20px;">Advanced Settings</h4>
<div class="form-row">
<div class="form-group"><label for="news_update_interval">Update Interval (sec)</label><input type="number" id="news_update_interval" class="form-control" value="{{ main_config.news_manager.update_interval }}"></div>
<div class="form-group"><label for="news_scroll_speed">Scroll Speed</label><input type="number" step="0.1" id="news_scroll_speed" class="form-control" value="{{ main_config.news_manager.scroll_speed }}"></div>
<div class="form-group"><label for="news_scroll_delay">Scroll Delay (sec)</label><input type="number" step="0.001" id="news_scroll_delay" class="form-control" value="{{ main_config.news_manager.scroll_delay }}"></div>
<div class="form-group"><label for="news_update_interval">Update Interval (sec)</label><input type="number" id="news_update_interval" class="form-control" value="{{ safe_config_get(main_config, 'news_manager', 'update_interval', default=300) }}"></div>
<div class="form-group"><label for="news_scroll_speed">Scroll Speed</label><input type="number" step="0.1" id="news_scroll_speed" class="form-control" value="{{ safe_config_get(main_config, 'news_manager', 'scroll_speed', default=1) }}"></div>
<div class="form-group"><label for="news_scroll_delay">Scroll Delay (sec)</label><input type="number" step="0.001" id="news_scroll_delay" class="form-control" value="{{ safe_config_get(main_config, 'news_manager', 'scroll_delay', default=0.01) }}"></div>
</div>
<div class="form-row">
<div class="form-group"><label for="news_rotation_threshold">Rotation Threshold</label><input type="number" id="news_rotation_threshold" class="form-control" value="{{ main_config.news_manager.rotation_threshold }}"></div>
<div class="form-group"><label><input type="checkbox" id="news_dynamic_duration" {% if main_config.news_manager.dynamic_duration %}checked{% endif %}> Dynamic Duration</label></div>
<div class="form-group"><label for="news_min_duration">Min Duration</label><input type="number" id="news_min_duration" class="form-control" value="{{ main_config.news_manager.min_duration }}"></div>
<div class="form-group"><label for="news_max_duration">Max Duration</label><input type="number" id="news_max_duration" class="form-control" value="{{ main_config.news_manager.max_duration }}"></div>
<div class="form-group"><label for="news_duration_buffer">Duration Buffer</label><input type="number" step="0.01" id="news_duration_buffer" class="form-control" value="{{ main_config.news_manager.duration_buffer }}"></div>
<div class="form-group"><label for="news_rotation_threshold">Rotation Threshold</label><input type="number" id="news_rotation_threshold" class="form-control" value="{{ safe_config_get(main_config, 'news_manager', 'rotation_threshold', default=3) }}"></div>
<div class="form-group"><label><input type="checkbox" id="news_dynamic_duration" {% if safe_config_get(main_config, 'news_manager', 'dynamic_duration', default=True) %}checked{% endif %}> Dynamic Duration</label></div>
<div class="form-group"><label for="news_min_duration">Min Duration</label><input type="number" id="news_min_duration" class="form-control" value="{{ safe_config_get(main_config, 'news_manager', 'min_duration', default=30) }}"></div>
<div class="form-group"><label for="news_max_duration">Max Duration</label><input type="number" id="news_max_duration" class="form-control" value="{{ safe_config_get(main_config, 'news_manager', 'max_duration', default=300) }}"></div>
<div class="form-group"><label for="news_duration_buffer">Duration Buffer</label><input type="number" step="0.01" id="news_duration_buffer" class="form-control" value="{{ safe_config_get(main_config, 'news_manager', 'duration_buffer', default=0.1) }}"></div>
</div>
<div class="form-row">
<div class="form-group"><label for="news_font_size">Font Size</label><input type="number" id="news_font_size" class="form-control" value="{{ main_config.news_manager.font_size }}"></div>
<div class="form-group"><label for="news_font_path">Font Path</label><input type="text" id="news_font_path" class="form-control" value="{{ main_config.news_manager.font_path }}"></div>
<div class="form-group"><label for="news_font_size">Font Size</label><input type="number" id="news_font_size" class="form-control" value="{{ safe_config_get(main_config, 'news_manager', 'font_size', default=8) }}"></div>
<div class="form-group"><label for="news_font_path">Font Path</label><input type="text" id="news_font_path" class="form-control" value="{{ safe_config_get(main_config, 'news_manager', 'font_path', default='assets/fonts/PressStart2P-Regular.ttf') }}"></div>
</div>
<div class="form-row">
<div class="form-group"><label for="news_text_color">Text Color</label><input type="color" id="news_text_color" class="form-control" data-rgb='{{ main_config.news_manager.text_color | tojson }}'></div>
<div class="form-group"><label for="news_separator_color">Separator Color</label><input type="color" id="news_separator_color" class="form-control" data-rgb='{{ main_config.news_manager.separator_color | tojson }}'></div>
<div class="form-group"><label for="news_text_color">Text Color</label><input type="color" id="news_text_color" class="form-control" data-rgb='{{ safe_config_get(main_config, 'news_manager', 'text_color', default=[255, 255, 255]) | tojson }}'></div>
<div class="form-group"><label for="news_separator_color">Separator Color</label><input type="color" id="news_separator_color" class="form-control" data-rgb='{{ safe_config_get(main_config, 'news_manager', 'separator_color', default=[255, 0, 0]) | tojson }}'></div>
</div>
<button class="btn btn-success" type="button" onclick="saveNewsAdvancedSettings()">Save Advanced News Settings</button>
</div>
@@ -2033,7 +2037,7 @@
<div id="notification" class="notification"></div>
<!-- Server-provided data for JS (avoids inline Jinja in JS) -->
<script id="serverData" type="application/json">{{ {'main_config': main_config, 'editor_mode': editor_mode} | tojson }}</script>
<script id="serverData" type="application/json">{{ {'main_config': main_config_data, 'editor_mode': editor_mode} | tojson }}</script>
<script>
// Global variables
@@ -2045,6 +2049,19 @@
let currentElements = [];
let selectedElement = null;
// Function to refresh the current config from the server
async function refreshCurrentConfig() {
try {
const response = await fetch('/api/config/main');
if (response.ok) {
const configData = await response.json();
currentConfig = configData;
}
} catch (error) {
console.warn('Failed to refresh current config:', error);
}
}
async function refreshOnDemandStatus(){
try{
const res = await fetch('/api/ondemand/status');
@@ -2487,6 +2504,9 @@
if (action === 'reboot_system' && !confirm('Are you sure you want to reboot the system?')) {
return;
}
if (action === 'migrate_config' && !confirm('This will migrate your configuration to add any new options with default values. A backup will be created automatically. Continue?')) {
return;
}
try {
const response = await fetch('/api/system/action', {
@@ -3038,6 +3058,41 @@
});
})();
// Music form submit
(function augmentMusicForm(){
const form = document.getElementById('music-form');
form.addEventListener('submit', async function(e){
e.preventDefault();
const payload = {
music: {
enabled: document.getElementById('music_enabled').checked,
preferred_source: document.getElementById('music_preferred_source').value,
YTM_COMPANION_URL: document.getElementById('ytm_companion_url').value,
POLLING_INTERVAL_SECONDS: parseInt(document.getElementById('music_polling_interval').value)
}
};
await saveConfigJson(payload);
});
})();
// Calendar form submit
(function augmentCalendarForm(){
const form = document.getElementById('calendar-form');
form.addEventListener('submit', async function(e){
e.preventDefault();
const calendars = document.getElementById('calendar_calendars').value.split(',').map(s => s.trim()).filter(Boolean);
const payload = {
calendar: {
enabled: document.getElementById('calendar_enabled').checked,
max_events: parseInt(document.getElementById('calendar_max_events').value),
update_interval: parseInt(document.getElementById('calendar_update_interval').value),
calendars: calendars
}
};
await saveConfigJson(payload);
});
})();
// News advanced save
async function saveNewsAdvancedSettings(){
const payload = {
@@ -3409,6 +3464,8 @@
// Sports configuration
async function refreshSportsConfig(){
try {
// Refresh the current config to ensure we have the latest data
await refreshCurrentConfig();
const res = await fetch('/api/system/status');
const stats = await res.json();
// Build a minimal sports UI off current config

View File

@@ -0,0 +1,96 @@
# Soccer Logo Checker and Downloader
## Overview
The `check_soccer_logos.py` script automatically checks for missing logos of major teams from supported soccer leagues and downloads them from ESPN API if missing.
## Supported Leagues
- **Premier League** (eng.1) - 20 teams
- **La Liga** (esp.1) - 15 teams
- **Bundesliga** (ger.1) - 15 teams
- **Serie A** (ita.1) - 14 teams
- **Ligue 1** (fra.1) - 12 teams
- **Liga Portugal** (por.1) - 15 teams
- **Champions League** (uefa.champions) - 13 major teams
- **Europa League** (uefa.europa) - 11 major teams
- **MLS** (usa.1) - 25 teams
**Total: 140 major teams across 9 leagues**
## Usage
```bash
cd test
python check_soccer_logos.py
```
## What It Does
1. **Checks Existing Logos**: Scans `assets/sports/soccer_logos/` for existing logo files
2. **Identifies Missing Logos**: Compares against the list of major teams
3. **Downloads from ESPN**: Automatically fetches missing logos from ESPN API
4. **Creates Placeholders**: If download fails, creates colored placeholder logos
5. **Provides Summary**: Shows detailed statistics of the process
## Output
The script provides detailed logging showing:
- ✅ Existing logos found
- ⬇️ Successfully downloaded logos
- ❌ Failed downloads (with placeholders created)
- 📊 Summary statistics
## Example Output
```
🔍 Checking por.1 (Liga Portugal)
📊 Found 2 existing logos, 13 missing
✅ Existing: BEN, POR
❌ Missing: ARO (Arouca), BRA (SC Braga), CHA (Chaves), ...
Downloading ARO (Arouca) from por.1
✅ Successfully downloaded ARO (Arouca)
...
📈 SUMMARY
✅ Existing logos: 25
⬇️ Downloaded: 115
❌ Failed downloads: 0
📊 Total teams checked: 140
```
## Logo Storage
All logos are stored in: `assets/sports/soccer_logos/`
Format: `{TEAM_ABBREVIATION}.png` (e.g., `BEN.png`, `POR.png`, `LIV.png`)
## Integration with LEDMatrix
These logos are automatically used by the soccer manager when displaying:
- Live games
- Recent games
- Upcoming games
- Odds ticker
- Leaderboards
The system will automatically download missing logos on-demand during normal operation, but this script ensures all major teams have logos available upfront.
## Notes
- **Real Logos**: Downloaded from ESPN's official API
- **Placeholders**: Created for teams not found in ESPN data
- **Caching**: Logos are cached locally to avoid repeated downloads
- **Format**: All logos converted to RGBA PNG format for LEDMatrix compatibility
- **Size**: Logos are optimized for LED matrix display (typically 36x36 pixels)
## Troubleshooting
If downloads fail:
1. Check internet connectivity
2. Verify ESPN API is accessible
3. Some teams may not be in current league rosters
4. Placeholder logos will be created as fallback
The script is designed to be robust and will always provide some form of logo for every team.

315
test/check_soccer_logos.py Normal file
View File

@@ -0,0 +1,315 @@
#!/usr/bin/env python3
"""
Soccer Logo Checker and Downloader
This script checks for missing logos of major teams from supported soccer leagues
and downloads them from ESPN API if missing.
Supported Leagues:
- Premier League (eng.1)
- La Liga (esp.1)
- Bundesliga (ger.1)
- Serie A (ita.1)
- Ligue 1 (fra.1)
- Liga Portugal (por.1)
- Champions League (uefa.champions)
- Europa League (uefa.europa)
- MLS (usa.1)
"""
import os
import sys
import logging
from pathlib import Path
from typing import Dict, List, Tuple
# Add src directory to path for imports
sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'src'))
from logo_downloader import download_missing_logo, get_soccer_league_key, LogoDownloader
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger(__name__)
# Major teams for each league (with their ESPN abbreviations)
MAJOR_TEAMS = {
'eng.1': { # Premier League
'ARS': 'Arsenal',
'AVL': 'Aston Villa',
'BHA': 'Brighton & Hove Albion',
'BOU': 'AFC Bournemouth',
'BRE': 'Brentford',
'BUR': 'Burnley',
'CHE': 'Chelsea',
'CRY': 'Crystal Palace',
'EVE': 'Everton',
'FUL': 'Fulham',
'LIV': 'Liverpool',
'LUT': 'Luton Town',
'MCI': 'Manchester City',
'MUN': 'Manchester United',
'NEW': 'Newcastle United',
'NFO': 'Nottingham Forest',
'SHU': 'Sheffield United',
'TOT': 'Tottenham Hotspur',
'WHU': 'West Ham United',
'WOL': 'Wolverhampton Wanderers'
},
'esp.1': { # La Liga
'ALA': 'Alavés',
'ATH': 'Athletic Bilbao',
'ATM': 'Atlético Madrid',
'BAR': 'Barcelona',
'BET': 'Real Betis',
'CEL': 'Celta Vigo',
'ESP': 'Espanyol',
'GET': 'Getafe',
'GIR': 'Girona',
'LEG': 'Leganés',
'RAY': 'Rayo Vallecano',
'RMA': 'Real Madrid',
'SEV': 'Sevilla',
'VAL': 'Valencia',
'VLD': 'Valladolid'
},
'ger.1': { # Bundesliga
'BOC': 'VfL Bochum',
'DOR': 'Borussia Dortmund',
'FCA': 'FC Augsburg',
'FCB': 'Bayern Munich',
'FCU': 'FC Union Berlin',
'KOL': '1. FC Köln',
'LEV': 'Bayer Leverkusen',
'M05': 'Mainz 05',
'RBL': 'RB Leipzig',
'SCF': 'SC Freiburg',
'SGE': 'Eintracht Frankfurt',
'STU': 'VfB Stuttgart',
'SVW': 'Werder Bremen',
'TSG': 'TSG Hoffenheim',
'WOB': 'VfL Wolfsburg'
},
'ita.1': { # Serie A
'ATA': 'Atalanta',
'CAG': 'Cagliari',
'EMP': 'Empoli',
'FIO': 'Fiorentina',
'INT': 'Inter Milan',
'JUV': 'Juventus',
'LAZ': 'Lazio',
'MIL': 'AC Milan',
'MON': 'Monza',
'NAP': 'Napoli',
'ROM': 'Roma',
'TOR': 'Torino',
'UDI': 'Udinese',
'VER': 'Hellas Verona'
},
'fra.1': { # Ligue 1
'LIL': 'Lille',
'LYON': 'Lyon',
'MAR': 'Marseille',
'MON': 'Monaco',
'NAN': 'Nantes',
'NICE': 'Nice',
'OL': 'Olympique Lyonnais',
'OM': 'Olympique de Marseille',
'PAR': 'Paris Saint-Germain',
'PSG': 'Paris Saint-Germain',
'REN': 'Rennes',
'STR': 'Strasbourg'
},
'por.1': { # Liga Portugal
'ARO': 'Arouca',
'BEN': 'SL Benfica',
'BRA': 'SC Braga',
'CHA': 'Chaves',
'EST': 'Estoril Praia',
'FAM': 'Famalicão',
'GIL': 'Gil Vicente',
'MOR': 'Moreirense',
'POR': 'FC Porto',
'PTM': 'Portimonense',
'RIO': 'Rio Ave',
'SR': 'Sporting CP',
'SCP': 'Sporting CP', # Alternative abbreviation
'VGU': 'Vitória de Guimarães',
'VSC': 'Vitória de Setúbal'
},
'uefa.champions': { # Champions League (major teams)
'AJX': 'Ajax',
'ATM': 'Atlético Madrid',
'BAR': 'Barcelona',
'BAY': 'Bayern Munich',
'CHE': 'Chelsea',
'INT': 'Inter Milan',
'JUV': 'Juventus',
'LIV': 'Liverpool',
'MCI': 'Manchester City',
'MUN': 'Manchester United',
'PSG': 'Paris Saint-Germain',
'RMA': 'Real Madrid',
'TOT': 'Tottenham Hotspur'
},
'uefa.europa': { # Europa League (major teams)
'ARS': 'Arsenal',
'ATM': 'Atlético Madrid',
'BAR': 'Barcelona',
'CHE': 'Chelsea',
'INT': 'Inter Milan',
'JUV': 'Juventus',
'LIV': 'Liverpool',
'MUN': 'Manchester United',
'NAP': 'Napoli',
'ROM': 'Roma',
'SEV': 'Sevilla'
},
'usa.1': { # MLS
'ATL': 'Atlanta United',
'AUS': 'Austin FC',
'CHI': 'Chicago Fire',
'CIN': 'FC Cincinnati',
'CLB': 'Columbus Crew',
'DAL': 'FC Dallas',
'DC': 'D.C. United',
'HOU': 'Houston Dynamo',
'LA': 'LA Galaxy',
'LAFC': 'Los Angeles FC',
'MIA': 'Inter Miami',
'MIN': 'Minnesota United',
'MTL': 'CF Montréal',
'NSC': 'Nashville SC',
'NYC': 'New York City FC',
'NYR': 'New York Red Bulls',
'ORL': 'Orlando City',
'PHI': 'Philadelphia Union',
'POR': 'Portland Timbers',
'RSL': 'Real Salt Lake',
'SEA': 'Seattle Sounders',
'SJ': 'San Jose Earthquakes',
'SKC': 'Sporting Kansas City',
'TOR': 'Toronto FC',
'VAN': 'Vancouver Whitecaps'
}
}
def check_logo_exists(team_abbr: str, logo_dir: str) -> bool:
"""Check if a logo file exists for the given team abbreviation."""
logo_path = os.path.join(logo_dir, f"{team_abbr}.png")
return os.path.exists(logo_path)
def download_team_logo(team_abbr: str, team_name: str, league_code: str) -> bool:
"""Download a team logo from ESPN API."""
try:
soccer_league_key = get_soccer_league_key(league_code)
logger.info(f"Downloading {team_abbr} ({team_name}) from {league_code}")
success = download_missing_logo(team_abbr, soccer_league_key, team_name)
if success:
logger.info(f"✅ Successfully downloaded {team_abbr} ({team_name})")
return True
else:
logger.warning(f"❌ Failed to download {team_abbr} ({team_name})")
return False
except Exception as e:
logger.error(f"❌ Error downloading {team_abbr} ({team_name}): {e}")
return False
def check_league_logos(league_code: str, teams: Dict[str, str], logo_dir: str) -> Tuple[int, int]:
"""Check and download missing logos for a specific league."""
logger.info(f"\n🔍 Checking {league_code} ({LEAGUE_NAMES.get(league_code, league_code)})")
missing_logos = []
existing_logos = []
# Check which logos are missing
for team_abbr, team_name in teams.items():
if check_logo_exists(team_abbr, logo_dir):
existing_logos.append(team_abbr)
else:
missing_logos.append((team_abbr, team_name))
logger.info(f"📊 Found {len(existing_logos)} existing logos, {len(missing_logos)} missing")
if existing_logos:
logger.info(f"✅ Existing: {', '.join(existing_logos)}")
if missing_logos:
logger.info(f"❌ Missing: {', '.join([f'{abbr} ({name})' for abbr, name in missing_logos])}")
# Download missing logos
downloaded_count = 0
failed_count = 0
for team_abbr, team_name in missing_logos:
if download_team_logo(team_abbr, team_name, league_code):
downloaded_count += 1
else:
failed_count += 1
return downloaded_count, failed_count
def main():
"""Main function to check and download all soccer logos."""
logger.info("⚽ Soccer Logo Checker and Downloader")
logger.info("=" * 50)
# Ensure logo directory exists
logo_dir = "assets/sports/soccer_logos"
os.makedirs(logo_dir, exist_ok=True)
logger.info(f"📁 Logo directory: {logo_dir}")
# League names for display
global LEAGUE_NAMES
LEAGUE_NAMES = {
'eng.1': 'Premier League',
'esp.1': 'La Liga',
'ger.1': 'Bundesliga',
'ita.1': 'Serie A',
'fra.1': 'Ligue 1',
'por.1': 'Liga Portugal',
'uefa.champions': 'Champions League',
'uefa.europa': 'Europa League',
'usa.1': 'MLS'
}
total_downloaded = 0
total_failed = 0
total_existing = 0
# Check each league
for league_code, teams in MAJOR_TEAMS.items():
downloaded, failed = check_league_logos(league_code, teams, logo_dir)
total_downloaded += downloaded
total_failed += failed
total_existing += len(teams) - downloaded - failed
# Summary
logger.info("\n" + "=" * 50)
logger.info("📈 SUMMARY")
logger.info("=" * 50)
logger.info(f"✅ Existing logos: {total_existing}")
logger.info(f"⬇️ Downloaded: {total_downloaded}")
logger.info(f"❌ Failed downloads: {total_failed}")
logger.info(f"📊 Total teams checked: {total_existing + total_downloaded + total_failed}")
if total_failed > 0:
logger.warning(f"\n⚠️ {total_failed} logos failed to download. This might be due to:")
logger.warning(" - Network connectivity issues")
logger.warning(" - ESPN API rate limiting")
logger.warning(" - Team abbreviations not matching ESPN's format")
logger.warning(" - Teams not currently in the league")
if total_downloaded > 0:
logger.info(f"\n🎉 Successfully downloaded {total_downloaded} new logos!")
logger.info(" These logos are now available for use in the LEDMatrix display.")
logger.info(f"\n📁 All logos are stored in: {os.path.abspath(logo_dir)}")
if __name__ == "__main__":
main()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Some files were not shown because too many files have changed in this diff Show More