Sports news ticker with dynamic headline scrolling (#9)

* Add news manager with RSS feed ticker and dynamic scrolling

Co-authored-by: charlesmynard <charlesmynard@gmail.com>

* Add F1 feeds, custom feed management script, and comprehensive feed guide

Co-authored-by: charlesmynard <charlesmynard@gmail.com>

* Remove emoji and improve error/success message formatting

Co-authored-by: charlesmynard <charlesmynard@gmail.com>

* Add dynamic duration feature for news display with configurable timing

Co-authored-by: charlesmynard <charlesmynard@gmail.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Chuck
2025-07-27 09:53:19 -05:00
committed by GitHub
parent ffc006bd99
commit b6751a94c2
10 changed files with 2131 additions and 27 deletions

245
CUSTOM_FEEDS_GUIDE.md Normal file
View File

@@ -0,0 +1,245 @@
# Adding Custom RSS Feeds & Sports - Complete Guide
This guide shows you **3 different ways** to add custom RSS feeds like F1, MotoGP, or any personal feeds to your news manager.
## Quick Examples
### F1 Racing Feeds
```bash
# BBC F1 (Recommended - works well)
python3 add_custom_feed_example.py add "BBC F1" "http://feeds.bbci.co.uk/sport/formula1/rss.xml"
# Motorsport.com F1
python3 add_custom_feed_example.py add "Motorsport F1" "https://www.motorsport.com/rss/f1/news/"
# Formula1.com Official
python3 add_custom_feed_example.py add "F1 Official" "https://www.formula1.com/en/latest/all.xml"
```
### Other Sports
```bash
# MotoGP
python3 add_custom_feed_example.py add "MotoGP" "https://www.motogp.com/en/rss/news"
# Tennis
python3 add_custom_feed_example.py add "Tennis" "https://www.atptour.com/en/rss/news"
# Golf
python3 add_custom_feed_example.py add "Golf" "https://www.pgatour.com/news.rss"
# Soccer/Football
python3 add_custom_feed_example.py add "ESPN Soccer" "https://www.espn.com/espn/rss/soccer/news"
```
### Personal/Blog Feeds
```bash
# Personal blog
python3 add_custom_feed_example.py add "My Blog" "https://myblog.com/rss.xml"
# Tech news
python3 add_custom_feed_example.py add "TechCrunch" "https://techcrunch.com/feed/"
# Local news
python3 add_custom_feed_example.py add "Local News" "https://localnews.com/rss"
```
---
## Method 1: Command Line (Easiest)
### Add a Feed
```bash
python3 add_custom_feed_example.py add "FEED_NAME" "RSS_URL"
```
### List All Feeds
```bash
python3 add_custom_feed_example.py list
```
### Remove a Feed
```bash
python3 add_custom_feed_example.py remove "FEED_NAME"
```
### Example: Adding F1
```bash
# Step 1: Check current feeds
python3 add_custom_feed_example.py list
# Step 2: Add BBC F1 feed
python3 add_custom_feed_example.py add "BBC F1" "http://feeds.bbci.co.uk/sport/formula1/rss.xml"
# Step 3: Verify it was added
python3 add_custom_feed_example.py list
```
---
## Method 2: Web Interface
1. **Open Web Interface**: Go to `http://your-display-ip:5000`
2. **Navigate to News Tab**: Click the "News Manager" tab
3. **Add Custom Feed**:
- Enter feed name in "Feed Name" field (e.g., "BBC F1")
- Enter RSS URL in "RSS Feed URL" field
- Click "Add Feed" button
4. **Enable the Feed**: Check the checkbox next to your new feed
5. **Save Settings**: Click "Save News Settings"
---
## Method 3: Direct Config Edit
Edit `config/config.json` directly:
```json
{
"news_manager": {
"enabled": true,
"enabled_feeds": ["NFL", "NCAA FB", "BBC F1"],
"custom_feeds": {
"BBC F1": "http://feeds.bbci.co.uk/sport/formula1/rss.xml",
"Motorsport F1": "https://www.motorsport.com/rss/f1/news/",
"My Blog": "https://myblog.com/rss.xml"
},
"headlines_per_feed": 2
}
}
```
---
## Finding RSS Feeds
### Popular Sports RSS Feeds
| Sport | Source | RSS URL |
|-------|--------|---------|
| **F1** | BBC Sport | `http://feeds.bbci.co.uk/sport/formula1/rss.xml` |
| **F1** | Motorsport.com | `https://www.motorsport.com/rss/f1/news/` |
| **MotoGP** | Official | `https://www.motogp.com/en/rss/news` |
| **Tennis** | ATP Tour | `https://www.atptour.com/en/rss/news` |
| **Golf** | PGA Tour | `https://www.pgatour.com/news.rss` |
| **Soccer** | ESPN | `https://www.espn.com/espn/rss/soccer/news` |
| **Boxing** | ESPN | `https://www.espn.com/espn/rss/boxing/news` |
| **UFC/MMA** | ESPN | `https://www.espn.com/espn/rss/mma/news` |
### How to Find RSS Feeds
1. **Look for RSS icons** on websites
2. **Check `/rss`, `/feed`, or `/rss.xml`** paths
3. **Use RSS discovery tools** like RSS Feed Finder
4. **Check site footers** for RSS links
### Testing RSS Feeds
```bash
# Test if a feed works before adding it
python3 -c "
import feedparser
import requests
url = 'YOUR_RSS_URL_HERE'
try:
response = requests.get(url, timeout=10)
feed = feedparser.parse(response.content)
print(f'SUCCESS: Feed works! Title: {feed.feed.get(\"title\", \"N/A\")}')
print(f'{len(feed.entries)} articles found')
if feed.entries:
print(f'Latest: {feed.entries[0].title}')
except Exception as e:
print(f'ERROR: {e}')
"
```
---
## Advanced Configuration
### Controlling Feed Behavior
```json
{
"news_manager": {
"headlines_per_feed": 3, // Headlines from each feed
"scroll_speed": 2, // Pixels per frame
"scroll_delay": 0.02, // Seconds between updates
"rotation_enabled": true, // Rotate content to avoid repetition
"rotation_threshold": 3, // Cycles before rotating
"update_interval": 300 // Seconds between feed updates
}
}
```
### Feed Priority
Feeds are displayed in the order they appear in `enabled_feeds`:
```json
"enabled_feeds": ["NFL", "BBC F1", "NCAA FB"] // NFL first, then F1, then NCAA
```
### Custom Display Names
You can use any display name for feeds:
```bash
python3 add_custom_feed_example.py add "Formula 1" "http://feeds.bbci.co.uk/sport/formula1/rss.xml"
python3 add_custom_feed_example.py add "Basketball News" "https://www.espn.com/espn/rss/nba/news"
```
---
## Troubleshooting
### Feed Not Working?
1. **Test the RSS URL** using the testing command above
2. **Check for HTTPS vs HTTP** - some feeds require secure connections
3. **Verify the feed format** - must be valid RSS or Atom
4. **Check rate limiting** - some sites block frequent requests
### Common Issues
- **403 Forbidden**: Site blocks automated requests (try different feed)
- **SSL Errors**: Use HTTP instead of HTTPS if available
- **No Content**: Feed might be empty or incorrectly formatted
- **Slow Loading**: Increase timeout in news manager settings
### Feed Alternatives
If one feed doesn't work, try alternatives:
- **ESPN feeds** sometimes have access restrictions
- **BBC feeds** are generally reliable
- **Official sport websites** often have RSS feeds
- **News aggregators** like Google News have topic-specific feeds
---
## Real-World Example: Complete F1 Setup
```bash
# 1. List current setup
python3 add_custom_feed_example.py list
# 2. Add multiple F1 sources for better coverage
python3 add_custom_feed_example.py add "BBC F1" "http://feeds.bbci.co.uk/sport/formula1/rss.xml"
python3 add_custom_feed_example.py add "Motorsport F1" "https://www.motorsport.com/rss/f1/news/"
# 3. Add other racing series
python3 add_custom_feed_example.py add "MotoGP" "https://www.motogp.com/en/rss/news"
# 4. Verify all feeds work
python3 simple_news_test.py
# 5. Check final configuration
python3 add_custom_feed_example.py list
```
Result: Your display will now rotate between NFL, NCAA FB, BBC F1, Motorsport F1, and MotoGP headlines!
---
## Pro Tips
1. **Start Small**: Add one feed at a time and test it
2. **Mix Sources**: Use multiple sources for the same sport for better coverage
3. **Monitor Performance**: Too many feeds can slow down updates
4. **Use Descriptive Names**: "BBC F1" is better than just "F1"
5. **Test Regularly**: RSS feeds can change or break over time
6. **Backup Config**: Save your `config.json` before making changes
---
**Need help?** The news manager is designed to be flexible and user-friendly. Start with the command line method - it's the easiest way to get started!

177
DYNAMIC_DURATION_GUIDE.md Normal file
View File

@@ -0,0 +1,177 @@
# Dynamic Duration Feature - Complete Guide
The news manager now includes intelligent **dynamic duration calculation** that automatically determines the exact time needed to display all your selected headlines without cutting off mid-scroll.
## How It Works
### Automatic Calculation
The system calculates the perfect display duration by:
1. **Measuring Text Width**: Calculates the exact pixel width of all headlines combined
2. **Computing Scroll Distance**: Determines how far text needs to scroll (display width + text width)
3. **Calculating Time**: Uses scroll speed and delay to compute exact timing
4. **Adding Buffer**: Includes configurable buffer time for smooth transitions
5. **Applying Limits**: Ensures duration stays within your min/max preferences
### Real-World Example
With current settings (4 feeds, 2 headlines each):
- **Total Headlines**: 8 headlines per cycle
- **Estimated Duration**: 57 seconds
- **Cycles per Hour**: ~63 cycles
- **Result**: Perfect timing, no cut-offs
## Configuration Options
### Core Settings
```json
{
"news_manager": {
"dynamic_duration": true, // Enable/disable feature
"min_duration": 30, // Minimum display time (seconds)
"max_duration": 300, // Maximum display time (seconds)
"duration_buffer": 0.1, // Buffer time (10% extra)
"headlines_per_feed": 2, // Headlines from each feed
"scroll_speed": 2, // Pixels per frame
"scroll_delay": 0.02 // Seconds per frame
}
}
```
### Duration Scenarios
| Scenario | Headlines | Est. Duration | Cycles/Hour |
|----------|-----------|---------------|-------------|
| **Light** | 4 headlines | 30s (min) | 120 |
| **Medium** | 6 headlines | 30s (min) | 120 |
| **Current** | 8 headlines | 57s | 63 |
| **Heavy** | 12 headlines | 85s | 42 |
| **Maximum** | 20+ headlines | 300s (max) | 12 |
## Benefits
### Perfect Timing
- **No Cut-offs**: Headlines never cut off mid-sentence
- **Complete Cycles**: Always shows full rotation of all selected content
- **Smooth Transitions**: Buffer time prevents jarring switches
### Intelligent Scaling
- **Adapts to Content**: More feeds = longer duration automatically
- **User Control**: Set your preferred min/max limits
- **Flexible**: Works with any combination of feeds and headlines
### Predictable Behavior
- **Consistent Experience**: Same content always takes same time
- **Reliable Cycling**: Know exactly when content will repeat
- **Configurable**: Adjust to your viewing preferences
## Usage Examples
### Command Line Testing
```bash
# Test dynamic duration calculations
python3 test_dynamic_duration.py
# Check current status
python3 test_dynamic_duration.py status
```
### Configuration Changes
```bash
# Add more feeds (increases duration)
python3 add_custom_feed_example.py add "Tennis" "https://www.atptour.com/en/rss/news"
# Check new duration
python3 test_dynamic_duration.py status
```
### Web Interface
1. Go to `http://display-ip:5000`
2. Click "News Manager" tab
3. Adjust "Duration Settings":
- **Min Duration**: Shortest acceptable cycle time
- **Max Duration**: Longest acceptable cycle time
- **Buffer**: Extra time for smooth transitions
## Advanced Configuration
### Fine-Tuning Duration
```json
{
"min_duration": 45, // Increase for longer minimum cycles
"max_duration": 180, // Decrease for shorter maximum cycles
"duration_buffer": 0.15 // Increase buffer for more transition time
}
```
### Scroll Speed Impact
```json
{
"scroll_speed": 3, // Faster scroll = shorter duration
"scroll_delay": 0.015 // Less delay = shorter duration
}
```
### Content Control
```json
{
"headlines_per_feed": 3, // More headlines = longer duration
"enabled_feeds": [ // More feeds = longer duration
"NFL", "NBA", "MLB", "NHL", "BBC F1", "Tennis"
]
}
```
## Troubleshooting
### Duration Too Short
- **Increase** `min_duration`
- **Add** more feeds or headlines per feed
- **Decrease** `scroll_speed`
### Duration Too Long
- **Decrease** `max_duration`
- **Remove** some feeds
- **Reduce** `headlines_per_feed`
- **Increase** `scroll_speed`
### Jerky Transitions
- **Increase** `duration_buffer`
- **Adjust** `scroll_delay`
## Disable Dynamic Duration
To use fixed timing instead:
```json
{
"dynamic_duration": false,
"fixed_duration": 60 // Fixed 60-second cycles
}
```
## Technical Details
### Calculation Formula
```
total_scroll_distance = display_width + text_width
frames_needed = total_scroll_distance / scroll_speed
base_time = frames_needed * scroll_delay
buffer_time = base_time * duration_buffer
final_duration = base_time + buffer_time (within min/max limits)
```
### Display Integration
The display controller automatically:
1. Calls `news_manager.get_dynamic_duration()`
2. Uses returned value for display timing
3. Switches to next mode after exact calculated time
4. Logs duration decisions for debugging
## Best Practices
1. **Start Conservative**: Use default settings initially
2. **Test Changes**: Use test script to preview duration changes
3. **Monitor Performance**: Watch for smooth transitions
4. **Adjust Gradually**: Make small changes to settings
5. **Consider Viewing**: Match duration to your typical viewing patterns
The dynamic duration feature ensures your news ticker always displays complete, perfectly-timed content cycles regardless of how many feeds or headlines you configure!

245
NEWS_MANAGER_README.md Normal file
View File

@@ -0,0 +1,245 @@
# Sports News Manager
A comprehensive RSS feed ticker system for displaying sports news headlines with dynamic scrolling and intelligent rotation.
## Features
### 🏈 Multiple Sports Feeds
- **NFL**: Latest NFL news and updates
- **NCAA Football**: College football news
- **MLB**: Major League Baseball news
- **NBA**: Basketball news and updates
- **NHL**: Hockey news
- **NCAA Basketball**: College basketball updates
- **Big 10**: Big Ten conference news
- **Top Sports**: General ESPN sports news
- **Custom Feeds**: Add your own RSS feeds
### 📺 Smart Display Features
- **Dynamic Length Detection**: Automatically calculates headline length and adjusts scroll timing
- **Perfect Spacing**: Ensures headlines don't cut off mid-text or loop unnecessarily
- **Intelligent Rotation**: Prevents repetitive content by rotating through different headlines
- **Configurable Speed**: Adjustable scroll speed and timing
- **Visual Separators**: Color-coded separators between different news sources
### ⚙️ Configuration Options
- Enable/disable individual sports feeds
- Set number of headlines per feed (1-5)
- Adjust scroll speed and timing
- Configure rotation behavior
- Customize fonts and colors
- Add custom RSS feeds
## Default RSS Feeds
The system comes pre-configured with these ESPN RSS feeds:
```
MLB: http://espn.com/espn/rss/mlb/news
NFL: http://espn.go.com/espn/rss/nfl/news
NCAA FB: https://www.espn.com/espn/rss/ncf/news
NHL: https://www.espn.com/espn/rss/nhl/news
NBA: https://www.espn.com/espn/rss/nba/news
TOP SPORTS: https://www.espn.com/espn/rss/news
BIG10: https://www.espn.com/blog/feed?blog=bigten
NCAA: https://www.espn.com/espn/rss/ncaa/news
Other: https://www.coveringthecorner.com/rss/current.xml
```
## Usage
### Command Line Management
Use the `enable_news_manager.py` script to manage the news manager:
```bash
# Check current status
python3 enable_news_manager.py status
# Enable news manager
python3 enable_news_manager.py enable
# Disable news manager
python3 enable_news_manager.py disable
```
### Web Interface
Access the news manager through the web interface:
1. Open your browser to `http://your-display-ip:5000`
2. Click on the "News Manager" tab
3. Configure your preferred settings:
- Enable/disable the news manager
- Select which sports feeds to display
- Set headlines per feed (1-5)
- Configure scroll speed and timing
- Add custom RSS feeds
- Enable/disable rotation
### Configuration File
Direct configuration via `config/config.json`:
```json
{
"news_manager": {
"enabled": true,
"update_interval": 300,
"scroll_speed": 2,
"scroll_delay": 0.02,
"headlines_per_feed": 2,
"enabled_feeds": ["NFL", "NCAA FB"],
"custom_feeds": {
"My Team": "https://example.com/rss"
},
"rotation_enabled": true,
"rotation_threshold": 3,
"font_size": 12,
"font_path": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"text_color": [255, 255, 255],
"separator_color": [255, 0, 0]
}
}
```
## How It Works
### Dynamic Length Calculation
The system intelligently calculates the display time for each headline:
1. **Text Measurement**: Uses PIL to measure the exact pixel width of each headline
2. **Scroll Distance**: Calculates total distance needed (text width + display width)
3. **Timing Calculation**: Determines exact scroll time based on speed settings
4. **Perfect Spacing**: Ensures smooth transitions between headlines
### Rotation Algorithm
Prevents repetitive content by:
1. **Tracking Display Count**: Monitors how many times each headline has been shown
2. **Threshold Management**: After a configured number of cycles, rotates to new content
3. **Feed Balancing**: Ensures even distribution across selected feeds
4. **Freshness**: Prioritizes newer headlines when available
### Example Calculation
For a headline "Breaking: Major trade shakes up NFL draft prospects" (51 characters):
- **Estimated Width**: ~306 pixels (6 pixels per character average)
- **Display Width**: 128 pixels
- **Total Scroll Distance**: 306 + 128 = 434 pixels
- **Scroll Speed**: 2 pixels per frame
- **Frame Delay**: 0.02 seconds
- **Total Time**: (434 ÷ 2) × 0.02 = 4.34 seconds
## Testing
### RSS Feed Test
Test the RSS feeds directly:
```bash
python3 simple_news_test.py
```
This will:
- Test connectivity to ESPN RSS feeds
- Parse sample headlines
- Calculate scroll timing
- Demonstrate rotation logic
### Integration Test
Test the full news manager without hardware dependencies:
```bash
python3 test_news_manager.py
```
## API Endpoints
The system provides REST API endpoints for external control:
- `GET /news_manager/status` - Get current status and configuration
- `POST /news_manager/update` - Update configuration
- `POST /news_manager/refresh` - Force refresh of news data
## Troubleshooting
### Common Issues
1. **RSS Feed Not Loading**
- Check internet connectivity
- Verify RSS URL is valid
- Check for rate limiting
2. **Slow Performance**
- Reduce number of enabled feeds
- Increase update interval
- Check network latency
3. **Text Not Displaying**
- Verify font path exists
- Check text color settings
- Ensure display dimensions are correct
### Debug Mode
Enable debug logging by setting the log level:
```python
import logging
logging.basicConfig(level=logging.DEBUG)
```
## Customization
### Adding Custom Feeds
Add your own RSS feeds through the web interface or configuration:
```json
"custom_feeds": {
"My Local Team": "https://myteam.com/rss",
"Sports Blog": "https://sportsblog.com/feed"
}
```
### Styling Options
Customize the appearance:
- **Font Size**: Adjust text size (8-24 pixels)
- **Colors**: RGB values for text and separators
- **Font Path**: Use different system fonts
- **Scroll Speed**: 1-10 pixels per frame
- **Timing**: 0.01-0.1 seconds per frame
## Performance
The news manager is optimized for:
- **Low Memory Usage**: Efficient caching and cleanup
- **Network Efficiency**: Smart update intervals and retry logic
- **Smooth Scrolling**: Consistent frame rates
- **Fast Loading**: Parallel RSS feed processing
## Future Enhancements
Planned features:
- Breaking news alerts
- Team-specific filtering
- Score integration
- Social media feeds
- Voice announcements
- Mobile app control
## Support
For issues or questions:
1. Check the troubleshooting section
2. Review the logs for error messages
3. Test individual RSS feeds
4. Verify configuration settings

162
add_custom_feed_example.py Normal file
View File

@@ -0,0 +1,162 @@
#!/usr/bin/env python3
import json
import sys
import os
def add_custom_feed(feed_name, feed_url):
"""Add a custom RSS feed to the news manager configuration"""
config_path = "config/config.json"
try:
# Load current config
with open(config_path, 'r') as f:
config = json.load(f)
# Ensure news_manager section exists
if 'news_manager' not in config:
print("ERROR: News manager configuration not found!")
return False
# Add custom feed
if 'custom_feeds' not in config['news_manager']:
config['news_manager']['custom_feeds'] = {}
config['news_manager']['custom_feeds'][feed_name] = feed_url
# Add to enabled feeds if not already there
if feed_name not in config['news_manager']['enabled_feeds']:
config['news_manager']['enabled_feeds'].append(feed_name)
# Save updated config
with open(config_path, 'w') as f:
json.dump(config, f, indent=4)
print(f"SUCCESS: Successfully added custom feed: {feed_name}")
print(f" URL: {feed_url}")
print(f" Feed is now enabled and will appear in rotation")
return True
except Exception as e:
print(f"ERROR: Error adding custom feed: {e}")
return False
def list_all_feeds():
"""List all available feeds (default + custom)"""
config_path = "config/config.json"
try:
with open(config_path, 'r') as f:
config = json.load(f)
news_config = config.get('news_manager', {})
custom_feeds = news_config.get('custom_feeds', {})
enabled_feeds = news_config.get('enabled_feeds', [])
print("\nAvailable News Feeds:")
print("=" * 50)
# Default feeds (hardcoded in news_manager.py)
default_feeds = {
'MLB': 'http://espn.com/espn/rss/mlb/news',
'NFL': 'http://espn.go.com/espn/rss/nfl/news',
'NCAA FB': 'https://www.espn.com/espn/rss/ncf/news',
'NHL': 'https://www.espn.com/espn/rss/nhl/news',
'NBA': 'https://www.espn.com/espn/rss/nba/news',
'TOP SPORTS': 'https://www.espn.com/espn/rss/news',
'BIG10': 'https://www.espn.com/blog/feed?blog=bigten',
'NCAA': 'https://www.espn.com/espn/rss/ncaa/news',
'Other': 'https://www.coveringthecorner.com/rss/current.xml'
}
print("\nDefault Sports Feeds:")
for name, url in default_feeds.items():
status = "ENABLED" if name in enabled_feeds else "DISABLED"
print(f" {name}: {status}")
print(f" {url}")
if custom_feeds:
print("\nCustom Feeds:")
for name, url in custom_feeds.items():
status = "ENABLED" if name in enabled_feeds else "DISABLED"
print(f" {name}: {status}")
print(f" {url}")
else:
print("\nCustom Feeds: None added yet")
print(f"\nCurrently Enabled Feeds: {len(enabled_feeds)}")
print(f" {', '.join(enabled_feeds)}")
except Exception as e:
print(f"ERROR: Error listing feeds: {e}")
def remove_custom_feed(feed_name):
"""Remove a custom RSS feed"""
config_path = "config/config.json"
try:
with open(config_path, 'r') as f:
config = json.load(f)
news_config = config.get('news_manager', {})
custom_feeds = news_config.get('custom_feeds', {})
if feed_name not in custom_feeds:
print(f"ERROR: Custom feed '{feed_name}' not found!")
return False
# Remove from custom feeds
del config['news_manager']['custom_feeds'][feed_name]
# Remove from enabled feeds if present
if feed_name in config['news_manager']['enabled_feeds']:
config['news_manager']['enabled_feeds'].remove(feed_name)
# Save updated config
with open(config_path, 'w') as f:
json.dump(config, f, indent=4)
print(f"SUCCESS: Successfully removed custom feed: {feed_name}")
return True
except Exception as e:
print(f"ERROR: Error removing custom feed: {e}")
return False
def main():
if len(sys.argv) < 2:
print("Usage:")
print(" python3 add_custom_feed_example.py list")
print(" python3 add_custom_feed_example.py add <feed_name> <feed_url>")
print(" python3 add_custom_feed_example.py remove <feed_name>")
print("\nExamples:")
print(" # Add F1 news feed")
print(" python3 add_custom_feed_example.py add 'F1' 'https://www.espn.com/espn/rss/rpm/news'")
print(" # Add BBC F1 feed")
print(" python3 add_custom_feed_example.py add 'BBC F1' 'http://feeds.bbci.co.uk/sport/formula1/rss.xml'")
print(" # Add personal blog feed")
print(" python3 add_custom_feed_example.py add 'My Blog' 'https://myblog.com/rss.xml'")
return
command = sys.argv[1].lower()
if command == 'list':
list_all_feeds()
elif command == 'add':
if len(sys.argv) != 4:
print("ERROR: Usage: python3 add_custom_feed_example.py add <feed_name> <feed_url>")
return
feed_name = sys.argv[2]
feed_url = sys.argv[3]
add_custom_feed(feed_name, feed_url)
elif command == 'remove':
if len(sys.argv) != 3:
print("ERROR: Usage: python3 add_custom_feed_example.py remove <feed_name>")
return
feed_name = sys.argv[2]
remove_custom_feed(feed_name)
else:
print(f"ERROR: Unknown command: {command}")
if __name__ == "__main__":
main()

View File

@@ -70,7 +70,8 @@
"ncaam_basketball_recent": 30,
"ncaam_basketball_upcoming": 30,
"music": 30,
"of_the_day": 40
"of_the_day": 40,
"news_manager": 60
},
"use_short_date_format": true
},
@@ -83,7 +84,7 @@
"enabled": false,
"update_interval": 1800,
"units": "imperial",
"display_format": "{temp}°F\n{condition}"
"display_format": "{temp}\u00b0F\n{condition}"
},
"stocks": {
"enabled": false,
@@ -92,7 +93,13 @@
"scroll_delay": 0.01,
"toggle_chart": false,
"symbols": [
"ASTS", "SCHD", "INTC", "NVDA", "T", "VOO", "SMCI"
"ASTS",
"SCHD",
"INTC",
"NVDA",
"T",
"VOO",
"SMCI"
],
"display_format": "{symbol}: ${price} ({change}%)"
},
@@ -100,7 +107,8 @@
"enabled": false,
"update_interval": 600,
"symbols": [
"BTC-USD", "ETH-USD"
"BTC-USD",
"ETH-USD"
],
"display_format": "{symbol}: ${price} ({change}%)"
},
@@ -119,7 +127,12 @@
"max_games_per_league": 5,
"show_odds_only": false,
"sort_order": "soonest",
"enabled_leagues": ["nfl","mlb", "ncaa_fb", "milb"],
"enabled_leagues": [
"nfl",
"mlb",
"ncaa_fb",
"milb"
],
"update_interval": 3600,
"scroll_speed": 1,
"scroll_delay": 0.01,
@@ -133,7 +146,9 @@
"token_file": "token.pickle",
"update_interval": 3600,
"max_events": 3,
"calendars": ["birthdays"]
"calendars": [
"birthdays"
]
},
"nhl_scoreboard": {
"enabled": false,
@@ -146,7 +161,9 @@
"recent_update_interval": 3600,
"upcoming_update_interval": 3600,
"show_favorite_teams_only": true,
"favorite_teams": ["TB"],
"favorite_teams": [
"TB"
],
"logo_dir": "assets/sports/nhl_logos",
"show_records": true,
"display_modes": {
@@ -169,7 +186,9 @@
"upcoming_update_interval": 3600,
"recent_game_hours": 72,
"show_favorite_teams_only": true,
"favorite_teams": ["DAL"],
"favorite_teams": [
"DAL"
],
"logo_dir": "assets/sports/nba_logos",
"show_records": true,
"display_modes": {
@@ -191,7 +210,10 @@
"recent_games_to_show": 0,
"upcoming_games_to_show": 2,
"show_favorite_teams_only": true,
"favorite_teams": ["TB", "DAL"],
"favorite_teams": [
"TB",
"DAL"
],
"logo_dir": "assets/sports/nfl_logos",
"show_records": true,
"display_modes": {
@@ -214,7 +236,10 @@
"recent_games_to_show": 0,
"upcoming_games_to_show": 2,
"show_favorite_teams_only": true,
"favorite_teams": ["UGA", "AUB"],
"favorite_teams": [
"UGA",
"AUB"
],
"logo_dir": "assets/sports/ncaa_fbs_logos",
"show_records": true,
"display_modes": {
@@ -235,7 +260,10 @@
"upcoming_update_interval": 3600,
"recent_game_hours": 72,
"show_favorite_teams_only": true,
"favorite_teams": ["UGA", "AUB"],
"favorite_teams": [
"UGA",
"AUB"
],
"logo_dir": "assets/sports/ncaa_fbs_logos",
"show_records": true,
"display_modes": {
@@ -254,7 +282,10 @@
"live_update_interval": 30,
"recent_game_hours": 72,
"show_favorite_teams_only": true,
"favorite_teams": ["UGA", "AUB"],
"favorite_teams": [
"UGA",
"AUB"
],
"logo_dir": "assets/sports/ncaa_fbs_logos",
"show_records": true,
"display_modes": {
@@ -282,7 +313,10 @@
"recent_games_to_show": 1,
"upcoming_games_to_show": 1,
"show_favorite_teams_only": true,
"favorite_teams": ["TB", "TEX"],
"favorite_teams": [
"TB",
"TEX"
],
"logo_dir": "assets/sports/mlb_logos",
"show_records": true,
"display_modes": {
@@ -302,7 +336,9 @@
"upcoming_update_interval": 3600,
"recent_games_to_show": 1,
"upcoming_games_to_show": 1,
"favorite_teams": ["TAM"],
"favorite_teams": [
"TAM"
],
"logo_dir": "assets/sports/milb_logos",
"show_records": true,
"upcoming_fetch_days": 7,
@@ -319,8 +355,16 @@
"font_size": 8,
"scroll": true,
"scroll_speed": 40,
"text_color": [255, 0, 0],
"background_color": [0, 0, 0],
"text_color": [
255,
0,
0
],
"background_color": [
0,
0,
0
],
"scroll_gap_width": 32
},
"soccer_scoreboard": {
@@ -335,8 +379,18 @@
"upcoming_update_interval": 3600,
"recent_game_hours": 168,
"show_favorite_teams_only": true,
"favorite_teams": ["LIV"],
"leagues": ["eng.1", "esp.1", "ger.1", "ita.1", "fra.1", "uefa.champions", "usa.1"],
"favorite_teams": [
"LIV"
],
"leagues": [
"eng.1",
"esp.1",
"ger.1",
"ita.1",
"fra.1",
"uefa.champions",
"usa.1"
],
"logo_dir": "assets/sports/soccer_logos",
"show_records": true,
"display_modes": {
@@ -356,7 +410,11 @@
"display_rotate_interval": 20,
"update_interval": 3600,
"subtitle_rotate_interval": 10,
"category_order": ["word_of_the_day", "slovenian_word_of_the_day", "bible_verse_of_the_day"],
"category_order": [
"word_of_the_day",
"slovenian_word_of_the_day",
"bible_verse_of_the_day"
],
"categories": {
"word_of_the_day": {
"enabled": true,
@@ -374,5 +432,40 @@
"display_name": "Bible Verse of the Day"
}
}
},
"news_manager": {
"enabled": true,
"update_interval": 300,
"scroll_speed": 2,
"scroll_delay": 0.02,
"headlines_per_feed": 2,
"enabled_feeds": [
"NFL",
"NCAA FB",
"F1",
"BBC F1"
],
"custom_feeds": {
"F1": "https://www.espn.com/espn/rss/rpm/news",
"BBC F1": "http://feeds.bbci.co.uk/sport/formula1/rss.xml"
},
"rotation_enabled": true,
"rotation_threshold": 3,
"dynamic_duration": true,
"min_duration": 30,
"max_duration": 300,
"duration_buffer": 0.1,
"font_size": 12,
"font_path": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"text_color": [
255,
255,
255
],
"separator_color": [
255,
0,
0
]
}
}

120
enable_news_manager.py Normal file
View File

@@ -0,0 +1,120 @@
#!/usr/bin/env python3
import json
import sys
import os
def enable_news_manager():
"""Enable the news manager in the configuration"""
config_path = "config/config.json"
try:
# Load current config
with open(config_path, 'r') as f:
config = json.load(f)
# Enable news manager
if 'news_manager' not in config:
print("News manager configuration not found!")
return False
config['news_manager']['enabled'] = True
# Save updated config
with open(config_path, 'w') as f:
json.dump(config, f, indent=4)
print("SUCCESS: News manager enabled successfully!")
print(f"Enabled feeds: {config['news_manager']['enabled_feeds']}")
print(f"Headlines per feed: {config['news_manager']['headlines_per_feed']}")
print(f"Update interval: {config['news_manager']['update_interval']} seconds")
return True
except Exception as e:
print(f"ERROR: Error enabling news manager: {e}")
return False
def disable_news_manager():
"""Disable the news manager in the configuration"""
config_path = "config/config.json"
try:
# Load current config
with open(config_path, 'r') as f:
config = json.load(f)
# Disable news manager
if 'news_manager' in config:
config['news_manager']['enabled'] = False
# Save updated config
with open(config_path, 'w') as f:
json.dump(config, f, indent=4)
print("SUCCESS: News manager disabled successfully!")
else:
print("News manager configuration not found!")
return True
except Exception as e:
print(f"ERROR: Error disabling news manager: {e}")
return False
def show_status():
"""Show current news manager status"""
config_path = "config/config.json"
try:
with open(config_path, 'r') as f:
config = json.load(f)
if 'news_manager' not in config:
print("News manager configuration not found!")
return
news_config = config['news_manager']
print("News Manager Status:")
print("=" * 30)
print(f"Enabled: {news_config.get('enabled', False)}")
print(f"Update Interval: {news_config.get('update_interval', 300)} seconds")
print(f"Scroll Speed: {news_config.get('scroll_speed', 2)} pixels/frame")
print(f"Scroll Delay: {news_config.get('scroll_delay', 0.02)} seconds/frame")
print(f"Headlines per Feed: {news_config.get('headlines_per_feed', 2)}")
print(f"Enabled Feeds: {news_config.get('enabled_feeds', [])}")
print(f"Rotation Enabled: {news_config.get('rotation_enabled', True)}")
print(f"Rotation Threshold: {news_config.get('rotation_threshold', 3)}")
print(f"Font Size: {news_config.get('font_size', 12)}")
custom_feeds = news_config.get('custom_feeds', {})
if custom_feeds:
print("Custom Feeds:")
for name, url in custom_feeds.items():
print(f" {name}: {url}")
else:
print("No custom feeds configured")
except Exception as e:
print(f"ERROR: Error reading configuration: {e}")
def main():
if len(sys.argv) < 2:
print("Usage: python3 enable_news_manager.py [enable|disable|status]")
sys.exit(1)
command = sys.argv[1].lower()
if command == "enable":
enable_news_manager()
elif command == "disable":
disable_news_manager()
elif command == "status":
show_status()
else:
print("Invalid command. Use: enable, disable, or status")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -33,6 +33,7 @@ from src.calendar_manager import CalendarManager
from src.text_display import TextDisplay
from src.music_manager import MusicManager
from src.of_the_day_manager import OfTheDayManager
from src.news_manager import NewsManager
# Get logger without configuring
logger = logging.getLogger(__name__)
@@ -61,9 +62,11 @@ class DisplayController:
self.youtube = YouTubeDisplay(self.display_manager, self.config) if self.config.get('youtube', {}).get('enabled', False) else None
self.text_display = TextDisplay(self.display_manager, self.config) if self.config.get('text_display', {}).get('enabled', False) else None
self.of_the_day = OfTheDayManager(self.display_manager, self.config) if self.config.get('of_the_day', {}).get('enabled', False) else None
self.news_manager = NewsManager(self.config, self.display_manager) if self.config.get('news_manager', {}).get('enabled', False) else None
logger.info(f"Calendar Manager initialized: {'Object' if self.calendar else 'None'}")
logger.info(f"Text Display initialized: {'Object' if self.text_display else 'None'}")
logger.info(f"OfTheDay Manager initialized: {'Object' if self.of_the_day else 'None'}")
logger.info(f"News Manager initialized: {'Object' if self.news_manager else 'None'}")
logger.info("Display modes initialized in %.3f seconds", time.time() - init_time)
# Initialize Music Manager
@@ -255,6 +258,7 @@ class DisplayController:
if self.youtube: self.available_modes.append('youtube')
if self.text_display: self.available_modes.append('text_display')
if self.of_the_day: self.available_modes.append('of_the_day')
if self.news_manager: self.available_modes.append('news_manager')
if self.music_manager:
self.available_modes.append('music')
# Add NHL display modes if enabled
@@ -439,6 +443,17 @@ class DisplayController:
"""Get the duration for the current display mode."""
mode_key = self.current_display_mode
# Handle dynamic duration for news manager
if mode_key == 'news_manager' and self.news_manager:
try:
dynamic_duration = self.news_manager.get_dynamic_duration()
logger.info(f"Using dynamic duration for news_manager: {dynamic_duration} seconds")
return dynamic_duration
except Exception as e:
logger.error(f"Error getting dynamic duration for news_manager: {e}")
# Fall back to configured duration
return self.display_durations.get(mode_key, 60)
# Simplify weather key handling
if mode_key.startswith('weather_'):
return self.display_durations.get(mode_key, 15)
@@ -461,6 +476,7 @@ class DisplayController:
if self.youtube: self.youtube.update()
if self.text_display: self.text_display.update()
if self.of_the_day: self.of_the_day.update(time.time())
if self.news_manager: self.news_manager.fetch_news_data()
# Update NHL managers
if self.nhl_live: self.nhl_live.update()
@@ -907,6 +923,8 @@ class DisplayController:
manager_to_display = self.text_display
elif self.current_display_mode == 'of_the_day' and self.of_the_day:
manager_to_display = self.of_the_day
elif self.current_display_mode == 'news_manager' and self.news_manager:
manager_to_display = self.news_manager
elif self.current_display_mode == 'nhl_recent' and self.nhl_recent:
manager_to_display = self.nhl_recent
elif self.current_display_mode == 'nhl_upcoming' and self.nhl_upcoming:
@@ -979,6 +997,8 @@ class DisplayController:
manager_to_display.display() # Assumes internal clearing
elif self.current_display_mode == 'of_the_day':
manager_to_display.display(force_clear=self.force_clear)
elif self.current_display_mode == 'news_manager':
manager_to_display.display_news()
elif self.current_display_mode == 'nfl_live' and self.nfl_live:
self.nfl_live.display(force_clear=self.force_clear)
elif self.current_display_mode == 'ncaa_fb_live' and self.ncaa_fb_live:

488
src/news_manager.py Normal file
View File

@@ -0,0 +1,488 @@
import time
import logging
import requests
import xml.etree.ElementTree as ET
import json
import random
from typing import Dict, Any, List, Tuple, Optional
from datetime import datetime, timedelta
import os
import urllib.parse
import re
import html
from src.config_manager import ConfigManager
from PIL import Image, ImageDraw, ImageFont
from src.cache_manager import CacheManager
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class NewsManager:
def __init__(self, config: Dict[str, Any], display_manager):
self.config = config
self.config_manager = ConfigManager()
self.display_manager = display_manager
self.news_config = config.get('news_manager', {})
self.last_update = 0
self.news_data = {}
self.current_headline_index = 0
self.scroll_position = 0
self.cached_text_image = None
self.cached_text = None
self.cache_manager = CacheManager()
self.current_headlines = []
self.headline_start_times = []
self.total_scroll_width = 0
self.headlines_displayed = set() # Track displayed headlines for rotation
self.dynamic_duration = 60 # Default duration in seconds
# Default RSS feeds
self.default_feeds = {
'MLB': 'http://espn.com/espn/rss/mlb/news',
'NFL': 'http://espn.go.com/espn/rss/nfl/news',
'NCAA FB': 'https://www.espn.com/espn/rss/ncf/news',
'NHL': 'https://www.espn.com/espn/rss/nhl/news',
'NBA': 'https://www.espn.com/espn/rss/nba/news',
'TOP SPORTS': 'https://www.espn.com/espn/rss/news',
'BIG10': 'https://www.espn.com/blog/feed?blog=bigten',
'NCAA': 'https://www.espn.com/espn/rss/ncaa/news',
'Other': 'https://www.coveringthecorner.com/rss/current.xml'
}
# Get scroll settings from config
self.scroll_speed = self.news_config.get('scroll_speed', 2)
self.scroll_delay = self.news_config.get('scroll_delay', 0.02)
self.update_interval = self.news_config.get('update_interval', 300) # 5 minutes
# Get headline settings from config
self.headlines_per_feed = self.news_config.get('headlines_per_feed', 2)
self.enabled_feeds = self.news_config.get('enabled_feeds', ['NFL', 'NCAA FB'])
self.custom_feeds = self.news_config.get('custom_feeds', {})
# Rotation settings
self.rotation_enabled = self.news_config.get('rotation_enabled', True)
self.rotation_threshold = self.news_config.get('rotation_threshold', 3) # After 3 full cycles
self.rotation_count = 0
# Dynamic duration settings
self.dynamic_duration_enabled = self.news_config.get('dynamic_duration', True)
self.min_duration = self.news_config.get('min_duration', 30)
self.max_duration = self.news_config.get('max_duration', 300)
self.duration_buffer = self.news_config.get('duration_buffer', 0.1)
# Font settings
self.font_size = self.news_config.get('font_size', 12)
self.font_path = self.news_config.get('font_path', '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf')
# Colors
self.text_color = tuple(self.news_config.get('text_color', [255, 255, 255]))
self.separator_color = tuple(self.news_config.get('separator_color', [255, 0, 0]))
# Initialize session with retry strategy
self.session = requests.Session()
retry_strategy = Retry(
total=3,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
)
adapter = HTTPAdapter(max_retries=retry_strategy)
self.session.mount("http://", adapter)
self.session.mount("https://", adapter)
logger.info(f"NewsManager initialized with feeds: {self.enabled_feeds}")
logger.info(f"Headlines per feed: {self.headlines_per_feed}")
logger.info(f"Scroll settings - Speed: {self.scroll_speed} pixels/frame, Delay: {self.scroll_delay*1000:.2f}ms")
def parse_rss_feed(self, url: str, feed_name: str) -> List[Dict[str, Any]]:
"""Parse RSS feed and return list of headlines"""
try:
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
response = self.session.get(url, headers=headers, timeout=10)
response.raise_for_status()
root = ET.fromstring(response.content)
headlines = []
# Handle different RSS formats
items = root.findall('.//item')
if not items:
items = root.findall('.//entry') # Atom feed format
for item in items[:self.headlines_per_feed * 2]: # Get extra to allow for filtering
title_elem = item.find('title')
if title_elem is not None:
title = html.unescape(title_elem.text or '').strip()
# Clean up title
title = re.sub(r'<[^>]+>', '', title) # Remove HTML tags
title = re.sub(r'\s+', ' ', title) # Normalize whitespace
if title and len(title) > 10: # Filter out very short titles
pub_date_elem = item.find('pubDate')
if pub_date_elem is None:
pub_date_elem = item.find('published') # Atom format
pub_date = pub_date_elem.text if pub_date_elem is not None else None
headlines.append({
'title': title,
'feed': feed_name,
'pub_date': pub_date,
'timestamp': datetime.now().isoformat()
})
logger.info(f"Parsed {len(headlines)} headlines from {feed_name}")
return headlines[:self.headlines_per_feed]
except Exception as e:
logger.error(f"Error parsing RSS feed {feed_name} ({url}): {e}")
return []
def fetch_news_data(self):
"""Fetch news from all enabled feeds"""
try:
all_headlines = []
# Combine default and custom feeds
all_feeds = {**self.default_feeds, **self.custom_feeds}
for feed_name in self.enabled_feeds:
if feed_name in all_feeds:
url = all_feeds[feed_name]
headlines = self.parse_rss_feed(url, feed_name)
all_headlines.extend(headlines)
else:
logger.warning(f"Feed '{feed_name}' not found in available feeds")
# Store headlines by feed for rotation management
self.news_data = {}
for headline in all_headlines:
feed = headline['feed']
if feed not in self.news_data:
self.news_data[feed] = []
self.news_data[feed].append(headline)
# Prepare current headlines for display
self.prepare_headlines_for_display()
self.last_update = time.time()
logger.info(f"Fetched {len(all_headlines)} total headlines from {len(self.enabled_feeds)} feeds")
except Exception as e:
logger.error(f"Error fetching news data: {e}")
def prepare_headlines_for_display(self):
"""Prepare headlines for scrolling display with rotation"""
if not self.news_data:
return
# Get headlines for display, applying rotation if enabled
display_headlines = []
for feed_name in self.enabled_feeds:
if feed_name in self.news_data:
feed_headlines = self.news_data[feed_name]
if self.rotation_enabled and len(feed_headlines) > self.headlines_per_feed:
# Rotate headlines to show different ones
start_idx = (self.rotation_count * self.headlines_per_feed) % len(feed_headlines)
selected = []
for i in range(self.headlines_per_feed):
idx = (start_idx + i) % len(feed_headlines)
selected.append(feed_headlines[idx])
display_headlines.extend(selected)
else:
display_headlines.extend(feed_headlines[:self.headlines_per_feed])
# Create scrolling text with separators
if display_headlines:
text_parts = []
for i, headline in enumerate(display_headlines):
feed_prefix = f"[{headline['feed']}] "
text_parts.append(feed_prefix + headline['title'])
# Join with separators and add spacing
separator = ""
self.cached_text = separator.join(text_parts) + "" # Add separator at end for smooth loop
# Calculate text dimensions for perfect scrolling
self.calculate_scroll_dimensions()
self.current_headlines = display_headlines
logger.info(f"Prepared {len(display_headlines)} headlines for display")
def calculate_scroll_dimensions(self):
"""Calculate exact dimensions needed for smooth scrolling"""
if not self.cached_text:
return
try:
# Load font
try:
font = ImageFont.truetype(self.font_path, self.font_size)
except:
font = ImageFont.load_default()
# Calculate text width
temp_img = Image.new('RGB', (1, 1))
temp_draw = ImageDraw.Draw(temp_img)
# Get text dimensions
bbox = temp_draw.textbbox((0, 0), self.cached_text, font=font)
self.total_scroll_width = bbox[2] - bbox[0]
# Calculate dynamic display duration
self.calculate_dynamic_duration()
logger.info(f"Text width calculated: {self.total_scroll_width} pixels")
logger.info(f"Dynamic duration calculated: {self.dynamic_duration} seconds")
except Exception as e:
logger.error(f"Error calculating scroll dimensions: {e}")
self.total_scroll_width = len(self.cached_text) * 8 # Fallback estimate
self.calculate_dynamic_duration()
def calculate_dynamic_duration(self):
"""Calculate the exact time needed to display all headlines"""
# If dynamic duration is disabled, use fixed duration from config
if not self.dynamic_duration_enabled:
self.dynamic_duration = self.news_config.get('fixed_duration', 60)
logger.info(f"Dynamic duration disabled, using fixed duration: {self.dynamic_duration}s")
return
if not self.total_scroll_width:
self.dynamic_duration = self.min_duration # Use configured minimum
return
try:
# Get display width (assume full width of display)
display_width = getattr(self.display_manager, 'width', 128) # Default to 128 if not available
# Calculate total scroll distance needed
# Text needs to scroll from right edge to completely off left edge
total_scroll_distance = display_width + self.total_scroll_width
# Calculate time based on scroll speed and delay
# scroll_speed = pixels per frame, scroll_delay = seconds per frame
frames_needed = total_scroll_distance / self.scroll_speed
total_time = frames_needed * self.scroll_delay
# Add buffer time for smooth cycling (configurable %)
buffer_time = total_time * self.duration_buffer
calculated_duration = int(total_time + buffer_time)
# Apply configured min/max limits
if calculated_duration < self.min_duration:
self.dynamic_duration = self.min_duration
logger.info(f"Duration capped to minimum: {self.min_duration}s")
elif calculated_duration > self.max_duration:
self.dynamic_duration = self.max_duration
logger.info(f"Duration capped to maximum: {self.max_duration}s")
else:
self.dynamic_duration = calculated_duration
logger.info(f"Dynamic duration calculation:")
logger.info(f" Display width: {display_width}px")
logger.info(f" Text width: {self.total_scroll_width}px")
logger.info(f" Total scroll distance: {total_scroll_distance}px")
logger.info(f" Frames needed: {frames_needed:.1f}")
logger.info(f" Base time: {total_time:.2f}s")
logger.info(f" Buffer time: {buffer_time:.2f}s ({self.duration_buffer*100}%)")
logger.info(f" Calculated duration: {calculated_duration}s")
logger.info(f" Final duration: {self.dynamic_duration}s")
except Exception as e:
logger.error(f"Error calculating dynamic duration: {e}")
self.dynamic_duration = self.min_duration # Use configured minimum as fallback
def should_update(self) -> bool:
"""Check if news data should be updated"""
return (time.time() - self.last_update) > self.update_interval
def get_news_display(self) -> Image.Image:
"""Generate the scrolling news ticker display"""
try:
# Update news if needed
if self.should_update() or not self.current_headlines:
self.fetch_news_data()
if not self.cached_text:
return self.create_no_news_image()
# Create display image
width = self.display_manager.width
height = self.display_manager.height
img = Image.new('RGB', (width, height), (0, 0, 0))
draw = ImageDraw.Draw(img)
# Load font
try:
font = ImageFont.truetype(self.font_path, self.font_size)
except:
font = ImageFont.load_default()
# Calculate vertical position (center the text)
text_height = self.font_size
y_pos = (height - text_height) // 2
# Calculate scroll position for smooth animation
if self.total_scroll_width > 0:
# Scroll from right to left
x_pos = width - self.scroll_position
# Draw the text
draw.text((x_pos, y_pos), self.cached_text, font=font, fill=self.text_color)
# If text has scrolled partially off screen, draw it again for seamless loop
if x_pos + self.total_scroll_width < width:
draw.text((x_pos + self.total_scroll_width, y_pos), self.cached_text, font=font, fill=self.text_color)
# Update scroll position
self.scroll_position += self.scroll_speed
# Reset scroll when text has completely passed
if self.scroll_position >= self.total_scroll_width:
self.scroll_position = 0
self.rotation_count += 1
# Check if we should rotate headlines
if (self.rotation_enabled and
self.rotation_count >= self.rotation_threshold and
any(len(headlines) > self.headlines_per_feed for headlines in self.news_data.values())):
self.prepare_headlines_for_display()
self.rotation_count = 0
return img
except Exception as e:
logger.error(f"Error generating news display: {e}")
return self.create_error_image(str(e))
def create_no_news_image(self) -> Image.Image:
"""Create image when no news is available"""
width = self.display_manager.width
height = self.display_manager.height
img = Image.new('RGB', (width, height), (0, 0, 0))
draw = ImageDraw.Draw(img)
try:
font = ImageFont.truetype(self.font_path, self.font_size)
except:
font = ImageFont.load_default()
text = "Loading news..."
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
x = (width - text_width) // 2
y = (height - text_height) // 2
draw.text((x, y), text, font=font, fill=self.text_color)
return img
def create_error_image(self, error_msg: str) -> Image.Image:
"""Create image for error display"""
width = self.display_manager.width
height = self.display_manager.height
img = Image.new('RGB', (width, height), (0, 0, 0))
draw = ImageDraw.Draw(img)
try:
font = ImageFont.truetype(self.font_path, max(8, self.font_size - 2))
except:
font = ImageFont.load_default()
text = f"News Error: {error_msg[:50]}..."
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
x = max(0, (width - text_width) // 2)
y = (height - text_height) // 2
draw.text((x, y), text, font=font, fill=(255, 0, 0))
return img
def display_news(self):
"""Main display method for news ticker"""
try:
while True:
img = self.get_news_display()
self.display_manager.display_image(img)
time.sleep(self.scroll_delay)
except KeyboardInterrupt:
logger.info("News display interrupted by user")
except Exception as e:
logger.error(f"Error in news display loop: {e}")
def add_custom_feed(self, name: str, url: str):
"""Add a custom RSS feed"""
if name not in self.custom_feeds:
self.custom_feeds[name] = url
# Update config
if 'news_manager' not in self.config:
self.config['news_manager'] = {}
self.config['news_manager']['custom_feeds'] = self.custom_feeds
self.config_manager.save_config(self.config)
logger.info(f"Added custom feed: {name} -> {url}")
def remove_custom_feed(self, name: str):
"""Remove a custom RSS feed"""
if name in self.custom_feeds:
del self.custom_feeds[name]
# Update config
self.config['news_manager']['custom_feeds'] = self.custom_feeds
self.config_manager.save_config(self.config)
logger.info(f"Removed custom feed: {name}")
def set_enabled_feeds(self, feeds: List[str]):
"""Set which feeds are enabled"""
self.enabled_feeds = feeds
# Update config
if 'news_manager' not in self.config:
self.config['news_manager'] = {}
self.config['news_manager']['enabled_feeds'] = self.enabled_feeds
self.config_manager.save_config(self.config)
logger.info(f"Updated enabled feeds: {self.enabled_feeds}")
# Refresh headlines
self.fetch_news_data()
def get_available_feeds(self) -> Dict[str, str]:
"""Get all available feeds (default + custom)"""
return {**self.default_feeds, **self.custom_feeds}
def get_feed_status(self) -> Dict[str, Any]:
"""Get status information about feeds"""
status = {
'enabled_feeds': self.enabled_feeds,
'available_feeds': list(self.get_available_feeds().keys()),
'headlines_per_feed': self.headlines_per_feed,
'last_update': self.last_update,
'total_headlines': sum(len(headlines) for headlines in self.news_data.values()),
'rotation_enabled': self.rotation_enabled,
'rotation_count': self.rotation_count,
'dynamic_duration': self.dynamic_duration
}
return status
def get_dynamic_duration(self) -> int:
"""Get the calculated dynamic duration for display"""
# Ensure we have current data and calculated duration
if not self.cached_text or self.dynamic_duration == 60:
# Try to refresh if we don't have current data
if self.should_update() or not self.current_headlines:
self.fetch_news_data()
return self.dynamic_duration

View File

@@ -48,6 +48,84 @@
display: none;
padding: 20px 0;
}
.checkbox-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
margin: 10px 0;
}
.checkbox-item {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #f9f9f9;
}
.checkbox-item label {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
cursor: pointer;
}
.custom-feeds-container {
border: 1px solid #ddd;
border-radius: 4px;
padding: 15px;
background-color: #f9f9f9;
}
.add-custom-feed {
display: flex;
gap: 10px;
margin-bottom: 15px;
align-items: center;
}
.custom-feed-item {
margin: 10px 0;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
background-color: white;
}
.custom-feed-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.remove-btn {
background-color: #ff4444;
color: white;
border: none;
padding: 4px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
}
.remove-btn:hover {
background-color: #cc0000;
}
.settings-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.status-container {
border: 1px solid #ddd;
border-radius: 4px;
padding: 15px;
background-color: #f0f8f0;
}
.status-info h4 {
margin-top: 0;
color: #2c5e2c;
}
.status-info p {
margin: 5px 0;
}
.button-group {
display: flex;
gap: 10px;
margin: 20px 0;
}
.form-section {
background: #f9f9f9;
padding: 20px;
@@ -401,6 +479,7 @@
<button class="tab-link" onclick="openTab(event, 'features')">Additional Features</button>
<button class="tab-link" onclick="openTab(event, 'music')">Music</button>
<button class="tab-link" onclick="openTab(event, 'calendar')">Calendar</button>
<button class="tab-link" onclick="openTab(event, 'news')">News Manager</button>
<button class="tab-link" onclick="openTab(event, 'secrets')">API Keys</button>
<button class="tab-link" onclick="openTab(event, 'actions')">Actions</button>
<button class="tab-link" onclick="openTab(event, 'raw-json')">Raw JSON</button>
@@ -2139,6 +2218,119 @@
</div>
</div>
<!-- News Manager Tab -->
<div id="news" class="tab-content">
<div class="form-section">
<h2>News Manager Configuration</h2>
<p>Configure RSS news feeds and scrolling ticker settings</p>
<div class="form-group">
<label for="news_enabled">Enable News Manager:</label>
<div class="toggle-container">
<label class="toggle-switch">
<input type="checkbox" id="news_enabled" name="news_enabled">
<span class="slider"></span>
</label>
</div>
</div>
<div class="form-group">
<label for="headlines_per_feed">Headlines Per Feed:</label>
<input type="number" id="headlines_per_feed" name="headlines_per_feed" min="1" max="5" value="2">
<div class="description">Number of headlines to show from each enabled feed</div>
</div>
<div class="form-group">
<label>Available News Feeds:</label>
<div class="checkbox-grid" id="news_feeds_grid">
<!-- Feeds will be populated by JavaScript -->
</div>
</div>
<div class="form-group">
<h3>Custom RSS Feeds</h3>
<div class="custom-feeds-container">
<div class="add-custom-feed">
<input type="text" id="custom_feed_name" placeholder="Feed Name" style="width: 200px;">
<input type="text" id="custom_feed_url" placeholder="RSS Feed URL" style="width: 400px;">
<button type="button" onclick="addCustomFeed()">Add Feed</button>
</div>
<div id="custom_feeds_list">
<!-- Custom feeds will be populated by JavaScript -->
</div>
</div>
</div>
<div class="form-group">
<h3>Scrolling Settings</h3>
<div class="settings-row">
<div>
<label for="scroll_speed">Scroll Speed:</label>
<input type="number" id="scroll_speed" min="1" max="10" value="2">
<div class="description">Pixels per frame</div>
</div>
<div>
<label for="scroll_delay">Scroll Delay (ms):</label>
<input type="number" id="scroll_delay" min="5" max="100" value="20">
<div class="description">Delay between scroll updates</div>
</div>
</div>
</div>
<div class="form-group">
<label for="dynamic_duration">Enable Dynamic Duration:</label>
<div class="toggle-container">
<label class="toggle-switch">
<input type="checkbox" id="dynamic_duration" name="dynamic_duration" checked>
<span class="slider"></span>
</label>
</div>
<div class="description">Automatically calculate display time based on headline length</div>
</div>
<div class="form-group">
<h3>Duration Settings</h3>
<div class="settings-row">
<div>
<label for="min_duration">Min Duration (s):</label>
<input type="number" id="min_duration" min="10" max="120" value="30">
<div class="description">Minimum display time</div>
</div>
<div>
<label for="max_duration">Max Duration (s):</label>
<input type="number" id="max_duration" min="60" max="600" value="300">
<div class="description">Maximum display time</div>
</div>
<div>
<label for="duration_buffer">Buffer (%):</label>
<input type="number" id="duration_buffer" min="0" max="50" value="10" step="5">
<div class="description">Extra time for smooth cycling</div>
</div>
</div>
</div>
<div class="form-group">
<label for="rotation_enabled">Enable Headline Rotation:</label>
<div class="toggle-container">
<label class="toggle-switch">
<input type="checkbox" id="rotation_enabled" name="rotation_enabled" checked>
<span class="slider"></span>
</label>
</div>
<div class="description">Rotate through different headlines to avoid repetition</div>
</div>
<div class="button-group">
<button type="button" onclick="saveNewsSettings()">Save News Settings</button>
<button type="button" onclick="refreshNewsStatus()">Refresh Status</button>
</div>
<div id="news_status" class="status-container" style="margin-top: 20px;">
<!-- Status will be populated by JavaScript -->
</div>
</div>
</div>
<!-- Secrets Tab -->
<div id="secrets" class="tab-content">
<div class="form-section">
@@ -3533,6 +3725,214 @@
logContent.textContent = `Error loading logs: ${error}`;
});
}
// News Manager Functions
let newsManagerData = {};
function loadNewsManagerData() {
fetch('/news_manager/status')
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
newsManagerData = data.data;
updateNewsManagerUI();
} else {
console.error('Error loading news manager data:', data.message);
}
})
.catch(error => {
console.error('Error loading news manager data:', error);
});
}
function updateNewsManagerUI() {
// Update enabled toggle
document.getElementById('news_enabled').checked = newsManagerData.enabled || false;
// Update headlines per feed
document.getElementById('headlines_per_feed').value = newsManagerData.headlines_per_feed || 2;
// Update rotation enabled
document.getElementById('rotation_enabled').checked = newsManagerData.rotation_enabled !== false;
// Populate available feeds
const feedsGrid = document.getElementById('news_feeds_grid');
feedsGrid.innerHTML = '';
if (newsManagerData.available_feeds) {
newsManagerData.available_feeds.forEach(feed => {
const isEnabled = newsManagerData.enabled_feeds.includes(feed);
const feedDiv = document.createElement('div');
feedDiv.className = 'checkbox-item';
feedDiv.innerHTML = `
<label>
<input type="checkbox" name="news_feed" value="${feed}" ${isEnabled ? 'checked' : ''}>
${feed}
</label>
`;
feedsGrid.appendChild(feedDiv);
});
}
// Populate custom feeds
updateCustomFeedsList();
// Update status
updateNewsStatus();
}
function updateCustomFeedsList() {
const customFeedsList = document.getElementById('custom_feeds_list');
customFeedsList.innerHTML = '';
if (newsManagerData.custom_feeds) {
Object.entries(newsManagerData.custom_feeds).forEach(([name, url]) => {
const feedDiv = document.createElement('div');
feedDiv.className = 'custom-feed-item';
feedDiv.innerHTML = `
<div class="custom-feed-info">
<strong>${name}</strong>: ${url}
<button type="button" onclick="removeCustomFeed('${name}')" class="remove-btn">Remove</button>
</div>
`;
customFeedsList.appendChild(feedDiv);
});
}
}
function updateNewsStatus() {
const statusDiv = document.getElementById('news_status');
const enabledFeeds = newsManagerData.enabled_feeds || [];
const totalFeeds = enabledFeeds.length + Object.keys(newsManagerData.custom_feeds || {}).length;
statusDiv.innerHTML = `
<div class="status-info">
<h4>Current Status</h4>
<p><strong>Enabled:</strong> ${newsManagerData.enabled ? 'Yes' : 'No'}</p>
<p><strong>Active Feeds:</strong> ${enabledFeeds.join(', ') || 'None'}</p>
<p><strong>Headlines per Feed:</strong> ${newsManagerData.headlines_per_feed || 2}</p>
<p><strong>Total Custom Feeds:</strong> ${Object.keys(newsManagerData.custom_feeds || {}).length}</p>
<p><strong>Rotation Enabled:</strong> ${newsManagerData.rotation_enabled !== false ? 'Yes' : 'No'}</p>
</div>
`;
}
function saveNewsSettings() {
// Get enabled feeds
const enabledFeeds = Array.from(document.querySelectorAll('input[name="news_feed"]:checked'))
.map(input => input.value);
const headlinesPerFeed = parseInt(document.getElementById('headlines_per_feed').value);
const enabled = document.getElementById('news_enabled').checked;
const rotationEnabled = document.getElementById('rotation_enabled').checked;
// Save enabled status first
fetch('/news_manager/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: enabled })
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
// Then save feed settings
return fetch('/news_manager/update_feeds', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled_feeds: enabledFeeds,
headlines_per_feed: headlinesPerFeed
})
});
} else {
throw new Error(data.message);
}
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
showMessage('News settings saved successfully!', 'success');
loadNewsManagerData(); // Refresh the data
} else {
showMessage('Error saving news settings: ' + data.message, 'error');
}
})
.catch(error => {
showMessage('Error saving news settings: ' + error, 'error');
});
}
function addCustomFeed() {
const name = document.getElementById('custom_feed_name').value.trim();
const url = document.getElementById('custom_feed_url').value.trim();
if (!name || !url) {
showMessage('Please enter both feed name and URL', 'error');
return;
}
fetch('/news_manager/add_custom_feed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name, url: url })
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
showMessage(data.message, 'success');
document.getElementById('custom_feed_name').value = '';
document.getElementById('custom_feed_url').value = '';
loadNewsManagerData(); // Refresh the data
} else {
showMessage('Error adding custom feed: ' + data.message, 'error');
}
})
.catch(error => {
showMessage('Error adding custom feed: ' + error, 'error');
});
}
function removeCustomFeed(name) {
if (!confirm(`Are you sure you want to remove the feed "${name}"?`)) {
return;
}
fetch('/news_manager/remove_custom_feed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name })
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
showMessage(data.message, 'success');
loadNewsManagerData(); // Refresh the data
} else {
showMessage('Error removing custom feed: ' + data.message, 'error');
}
})
.catch(error => {
showMessage('Error removing custom feed: ' + error, 'error');
});
}
function refreshNewsStatus() {
loadNewsManagerData();
showMessage('News status refreshed', 'success');
}
// Load news manager data when the news tab is opened
document.addEventListener('DOMContentLoaded', function() {
// Override the openTab function to load news data when news tab is opened
const originalOpenTab = window.openTab;
window.openTab = function(evt, tabName) {
originalOpenTab(evt, tabName);
if (tabName === 'news') {
loadNewsManagerData();
}
};
});
</script>
</body>
</html>

View File

@@ -351,5 +351,159 @@ def save_raw_json_route():
'message': f'Error saving raw JSON: {str(e)}'
}), 400
@app.route('/news_manager/status', methods=['GET'])
def get_news_manager_status():
"""Get news manager status and configuration"""
try:
config = config_manager.load_config()
news_config = config.get('news_manager', {})
# Try to get status from the running display controller if possible
status = {
'enabled': news_config.get('enabled', False),
'enabled_feeds': news_config.get('enabled_feeds', []),
'available_feeds': [
'MLB', 'NFL', 'NCAA FB', 'NHL', 'NBA', 'TOP SPORTS',
'BIG10', 'NCAA', 'Other'
],
'headlines_per_feed': news_config.get('headlines_per_feed', 2),
'rotation_enabled': news_config.get('rotation_enabled', True),
'custom_feeds': news_config.get('custom_feeds', {})
}
return jsonify({
'status': 'success',
'data': status
})
except Exception as e:
return jsonify({
'status': 'error',
'message': f'Error getting news manager status: {str(e)}'
}), 400
@app.route('/news_manager/update_feeds', methods=['POST'])
def update_news_feeds():
"""Update enabled news feeds"""
try:
data = request.get_json()
enabled_feeds = data.get('enabled_feeds', [])
headlines_per_feed = data.get('headlines_per_feed', 2)
config = config_manager.load_config()
if 'news_manager' not in config:
config['news_manager'] = {}
config['news_manager']['enabled_feeds'] = enabled_feeds
config['news_manager']['headlines_per_feed'] = headlines_per_feed
config_manager.save_config(config)
return jsonify({
'status': 'success',
'message': 'News feeds updated successfully!'
})
except Exception as e:
return jsonify({
'status': 'error',
'message': f'Error updating news feeds: {str(e)}'
}), 400
@app.route('/news_manager/add_custom_feed', methods=['POST'])
def add_custom_news_feed():
"""Add a custom RSS feed"""
try:
data = request.get_json()
name = data.get('name', '').strip()
url = data.get('url', '').strip()
if not name or not url:
return jsonify({
'status': 'error',
'message': 'Name and URL are required'
}), 400
config = config_manager.load_config()
if 'news_manager' not in config:
config['news_manager'] = {}
if 'custom_feeds' not in config['news_manager']:
config['news_manager']['custom_feeds'] = {}
config['news_manager']['custom_feeds'][name] = url
config_manager.save_config(config)
return jsonify({
'status': 'success',
'message': f'Custom feed "{name}" added successfully!'
})
except Exception as e:
return jsonify({
'status': 'error',
'message': f'Error adding custom feed: {str(e)}'
}), 400
@app.route('/news_manager/remove_custom_feed', methods=['POST'])
def remove_custom_news_feed():
"""Remove a custom RSS feed"""
try:
data = request.get_json()
name = data.get('name', '').strip()
if not name:
return jsonify({
'status': 'error',
'message': 'Feed name is required'
}), 400
config = config_manager.load_config()
custom_feeds = config.get('news_manager', {}).get('custom_feeds', {})
if name in custom_feeds:
del custom_feeds[name]
config_manager.save_config(config)
return jsonify({
'status': 'success',
'message': f'Custom feed "{name}" removed successfully!'
})
else:
return jsonify({
'status': 'error',
'message': f'Custom feed "{name}" not found'
}), 404
except Exception as e:
return jsonify({
'status': 'error',
'message': f'Error removing custom feed: {str(e)}'
}), 400
@app.route('/news_manager/toggle', methods=['POST'])
def toggle_news_manager():
"""Toggle news manager on/off"""
try:
data = request.get_json()
enabled = data.get('enabled', False)
config = config_manager.load_config()
if 'news_manager' not in config:
config['news_manager'] = {}
config['news_manager']['enabled'] = enabled
config_manager.save_config(config)
return jsonify({
'status': 'success',
'message': f'News manager {"enabled" if enabled else "disabled"} successfully!'
})
except Exception as e:
return jsonify({
'status': 'error',
'message': f'Error toggling news manager: {str(e)}'
}), 400
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)