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
2
.gitignore
vendored
@@ -5,6 +5,8 @@ __pycache__/
|
||||
|
||||
# Secrets
|
||||
config/config_secrets.json
|
||||
config/config.json
|
||||
config/config.json.backup
|
||||
credentials.json
|
||||
token.pickle
|
||||
|
||||
|
||||
36
README.md
@@ -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.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 490 B After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 430 B After Width: | Height: | Size: 105 KiB |
BIN
assets/sports/soccer_logos/AJX.png
Normal file
|
After Width: | Height: | Size: 558 B |
BIN
assets/sports/soccer_logos/ARO.png
Normal file
|
After Width: | Height: | Size: 298 KiB |
BIN
assets/sports/soccer_logos/AUS.png
Normal file
|
After Width: | Height: | Size: 577 B |
BIN
assets/sports/soccer_logos/BAY.png
Normal file
|
After Width: | Height: | Size: 545 B |
BIN
assets/sports/soccer_logos/BEN.png
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
assets/sports/soccer_logos/BRA.png
Normal file
|
After Width: | Height: | Size: 541 B |
BIN
assets/sports/soccer_logos/BUR.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
assets/sports/soccer_logos/CHA.png
Normal file
|
After Width: | Height: | Size: 541 B |
BIN
assets/sports/soccer_logos/DOR.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
assets/sports/soccer_logos/EST.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
assets/sports/soccer_logos/FAM.png
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
assets/sports/soccer_logos/GIL.png
Normal file
|
After Width: | Height: | Size: 406 B |
BIN
assets/sports/soccer_logos/KOL.png
Normal file
|
After Width: | Height: | Size: 487 B |
BIN
assets/sports/soccer_logos/LEV.png
Normal file
|
After Width: | Height: | Size: 432 B |
BIN
assets/sports/soccer_logos/LUT.png
Normal file
|
After Width: | Height: | Size: 331 B |
BIN
assets/sports/soccer_logos/LYON.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
assets/sports/soccer_logos/MAR.png
Normal file
|
After Width: | Height: | Size: 581 B |
BIN
assets/sports/soccer_logos/MOR.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
assets/sports/soccer_logos/MTL.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
assets/sports/soccer_logos/NICE.png
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
assets/sports/soccer_logos/NSC.png
Normal file
|
After Width: | Height: | Size: 612 B |
BIN
assets/sports/soccer_logos/NYC.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
assets/sports/soccer_logos/NYR.png
Normal file
|
After Width: | Height: | Size: 495 B |
BIN
assets/sports/soccer_logos/PSG.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
assets/sports/soccer_logos/PTM.png
Normal file
|
After Width: | Height: | Size: 471 B |
BIN
assets/sports/soccer_logos/RIO.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
assets/sports/soccer_logos/SCP.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
assets/sports/soccer_logos/SHU.png
Normal file
|
After Width: | Height: | Size: 468 B |
BIN
assets/sports/soccer_logos/STU.png
Normal file
|
After Width: | Height: | Size: 484 B |
BIN
assets/sports/soccer_logos/VGU.png
Normal file
|
After Width: | Height: | Size: 560 B |
BIN
assets/sports/soccer_logos/VSC.png
Normal file
|
After Width: | Height: | Size: 635 B |
@@ -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,7 +305,6 @@
|
||||
],
|
||||
"logo_dir": "assets/sports/ncaa_logos",
|
||||
"show_records": true,
|
||||
"show_ranking": true,
|
||||
"display_modes": {
|
||||
"ncaa_fb_live": true,
|
||||
"ncaa_fb_recent": 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,
|
||||
@@ -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
@@ -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
@@ -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!"
|
||||
1230
milb_main.py
@@ -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()
|
||||
@@ -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')
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -5,7 +5,10 @@ from datetime import date
|
||||
from PIL import ImageDraw, ImageFont
|
||||
from src.config_manager import ConfigManager
|
||||
import time
|
||||
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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
96
test/README_soccer_logos.md
Normal 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
@@ -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()
|
||||
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 26 KiB |