diff --git a/CUSTOM_FEEDS_GUIDE.md b/CUSTOM_FEEDS_GUIDE.md new file mode 100644 index 00000000..55de233e --- /dev/null +++ b/CUSTOM_FEEDS_GUIDE.md @@ -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! \ No newline at end of file diff --git a/DYNAMIC_DURATION_GUIDE.md b/DYNAMIC_DURATION_GUIDE.md new file mode 100644 index 00000000..c3719778 --- /dev/null +++ b/DYNAMIC_DURATION_GUIDE.md @@ -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! \ No newline at end of file diff --git a/NEWS_MANAGER_README.md b/NEWS_MANAGER_README.md new file mode 100644 index 00000000..23e449bd --- /dev/null +++ b/NEWS_MANAGER_README.md @@ -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 \ No newline at end of file diff --git a/add_custom_feed_example.py b/add_custom_feed_example.py new file mode 100644 index 00000000..9e18578b --- /dev/null +++ b/add_custom_feed_example.py @@ -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 ") + print(" python3 add_custom_feed_example.py remove ") + 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 ") + 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 ") + return + feed_name = sys.argv[2] + remove_custom_feed(feed_name) + else: + print(f"ERROR: Unknown command: {command}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/config/config.json b/config/config.json index ef576e9a..2f5466ff 100644 --- a/config/config.json +++ b/config/config.json @@ -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": { @@ -264,11 +295,11 @@ } }, "youtube": { - "enabled": false, + "enabled": false, "update_interval": 3600 }, "mlb": { - "enabled": false, + "enabled": false, "live_priority": true, "live_game_duration": 30, "show_odds": true, @@ -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": { @@ -292,7 +326,7 @@ } }, "milb": { - "enabled": false, + "enabled": false, "live_priority": true, "live_game_duration": 30, "test_mode": false, @@ -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, @@ -315,12 +351,20 @@ "text_display": { "enabled": false, "text": "Subscribe to ChuckBuilds", - "font_path": "assets/fonts/press-start-2p.ttf", + "font_path": "assets/fonts/press-start-2p.ttf", "font_size": 8, "scroll": true, - "scroll_speed": 40, - "text_color": [255, 0, 0], - "background_color": [0, 0, 0], + "scroll_speed": 40, + "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 + ] } -} \ No newline at end of file +} \ No newline at end of file diff --git a/enable_news_manager.py b/enable_news_manager.py new file mode 100644 index 00000000..c7891ee5 --- /dev/null +++ b/enable_news_manager.py @@ -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() \ No newline at end of file diff --git a/src/display_controller.py b/src/display_controller.py index 1c40b349..e69d7f0a 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -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: diff --git a/src/news_manager.py b/src/news_manager.py new file mode 100644 index 00000000..8ce98b31 --- /dev/null +++ b/src/news_manager.py @@ -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 \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 6493e939..cdd944ad 100644 --- a/templates/index.html +++ b/templates/index.html @@ -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; @@ -400,8 +478,9 @@ - - + + + @@ -2139,6 +2218,119 @@ + +
+
+

News Manager Configuration

+

Configure RSS news feeds and scrolling ticker settings

+ +
+ +
+ +
+
+ +
+ + +
Number of headlines to show from each enabled feed
+
+ +
+ +
+ +
+
+ +
+

Custom RSS Feeds

+
+
+ + + +
+
+ +
+
+
+ +
+

Scrolling Settings

+
+
+ + +
Pixels per frame
+
+
+ + +
Delay between scroll updates
+
+
+
+ +
+ +
+ +
+
Automatically calculate display time based on headline length
+
+ +
+

Duration Settings

+
+
+ + +
Minimum display time
+
+
+ + +
Maximum display time
+
+
+ + +
Extra time for smooth cycling
+
+
+
+ +
+ +
+ +
+
Rotate through different headlines to avoid repetition
+
+ +
+ + +
+ +
+ +
+
+
+
@@ -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 = ` + + `; + 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 = ` +
+ ${name}: ${url} + +
+ `; + 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 = ` +
+

Current Status

+

Enabled: ${newsManagerData.enabled ? 'Yes' : 'No'}

+

Active Feeds: ${enabledFeeds.join(', ') || 'None'}

+

Headlines per Feed: ${newsManagerData.headlines_per_feed || 2}

+

Total Custom Feeds: ${Object.keys(newsManagerData.custom_feeds || {}).length}

+

Rotation Enabled: ${newsManagerData.rotation_enabled !== false ? 'Yes' : 'No'}

+
+ `; + } + + 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(); + } + }; + }); + \ No newline at end of file diff --git a/web_interface.py b/web_interface.py index 2c6ddf11..54e6de1a 100644 --- a/web_interface.py +++ b/web_interface.py @@ -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) \ No newline at end of file