Initial release of MbetterClient v1.0.0

- Complete cross-platform multimedia client application
- PyQt5 video player with dynamic overlay templates
- Flask web dashboard with JWT authentication
- REST API client with configurable endpoints
- Multi-threaded architecture with Queue-based messaging
- SQLite database with automatic migrations
- PyInstaller build configuration for executables
- Comprehensive documentation and build scripts
- Offline-first design with local asset fallbacks
parents
Pipeline #174 failed with stages
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Project specific
config/
logs/
videos/
*.db
*.sqlite
*.sqlite3
data/
temp/
build_output/
\ No newline at end of file
# Changelog
All notable changes to MbetterClient will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.0.0] - 2025-01-19
### Added
- Initial release of MbetterClient
- PyQt5 video player with hardware acceleration support
- Dynamic overlay system with three built-in templates:
- News template with scrolling ticker
- Sports template with live scores
- Simple template for basic text overlays
- Flask-based web dashboard with modern UI
- JWT authentication with role-based access control
- User management system with admin and regular user roles
- API token management for secure API access
- REST API client with configurable endpoints
- Automatic retry logic and error handling for API requests
- Built-in response handlers for news and sports APIs
- SQLite database with automatic schema migrations
- Multi-threaded architecture with Queue-based message passing
- Cross-platform executable generation with PyInstaller
- Offline-first design with local asset fallbacks
- Comprehensive configuration management
- Real-time system status monitoring
- Application logging with rotation and filtering
- Command-line interface with multiple options
- Web-based configuration interface
- Template-based video overlays with real-time data
- Fullscreen video playback support
- Video control via web dashboard and keyboard shortcuts
### Security
- Password hashing with salt
- Secure session management
- CSRF protection for web forms
- JWT token expiration and refresh
- API rate limiting and validation
- Secure configuration file handling
### Performance
- Hardware-accelerated video playback
- Efficient message bus with priority handling
- Lazy loading of video codecs
- Optimized database queries with connection pooling
- Memory-efficient overlay rendering
- Compressed static assets for web dashboard
### Documentation
- Complete user documentation
- API reference with examples
- Development guide with contribution guidelines
- Troubleshooting guide
- Build and deployment instructions
- Code examples and tutorials
### Supported Platforms
- Windows 10 and later
- macOS 10.14 and later
- Linux (Ubuntu 18.04+, CentOS 7+, Debian 10+)
### Dependencies
- Python 3.8+
- PyQt5 5.15.10
- Flask 3.0.3
- SQLAlchemy 2.0.25
- Requests 2.31.0
- PyInstaller 6.3.0
- And other dependencies listed in requirements.txt
### Known Issues
- Large video files may cause memory issues on systems with < 1GB RAM
- Some video codecs require additional system libraries on Linux
- Windows Defender may flag executables built with PyInstaller
### Migration Notes
- This is the initial release, no migration required
- Default admin credentials must be changed on first login
- Configuration files are created automatically on first run
## [Future Releases]
### Planned Features
- Plugin system for custom extensions
- Advanced video effects and transitions
- WebRTC streaming support
- Mobile device remote control
- Multi-monitor support with independent overlays
- Cloud configuration synchronization
- Advanced analytics and reporting
- Integration with popular streaming platforms
- Real-time collaboration features
- Advanced template editor with drag-and-drop interface
### Potential Breaking Changes
- Configuration file format may change in v2.0
- Message bus API may be redesigned for better performance
- Database schema updates may require manual migration
- Web dashboard API may be versioned for backward compatibility
---
## Version History
| Version | Release Date | Notes |
|---------|-------------|--------|
| 1.0.0 | 2025-01-19 | Initial release |
## Support Policy
- **Current Version (1.x)**: Full support with bug fixes and security updates
- **Previous Versions**: Security updates only for 6 months after new major release
- **LTS Versions**: Will be designated for enterprise users starting with v2.0
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute to this project.
## License
This project is licensed under the MIT License - see [LICENSE](LICENSE) for details.
\ No newline at end of file
# MbetterClient - Complete Documentation
## Table of Contents
1. [Installation & Setup](#installation--setup)
2. [Configuration](#configuration)
3. [Usage Guide](#usage-guide)
4. [API Reference](#api-reference)
5. [Development Guide](#development-guide)
6. [Troubleshooting](#troubleshooting)
7. [Advanced Topics](#advanced-topics)
## Installation & Setup
### System Requirements
- **Operating System**: Windows 10+, macOS 10.14+, or Linux (Ubuntu 18.04+)
- **Python**: 3.8 or higher
- **Memory**: 512 MB RAM minimum, 1 GB recommended
- **Storage**: 100 MB free space
- **Network**: Optional (for REST API features)
### Detailed Installation
1. **Install Python Dependencies**
```bash
# Ensure you have the latest pip
python -m pip install --upgrade pip
# Install all dependencies
pip install -r requirements.txt
# For development
pip install pytest pytest-qt black pylint
```
2. **Verify Installation**
```bash
python main.py --help
```
3. **First Run Setup**
- Application creates configuration directory automatically
- Default admin user is created (username: admin, password: admin)
- SQLite database is initialized with schema
- Web server starts on http://localhost:5000
### Directory Structure After Installation
```
~/.config/MbetterClient/ # Linux
~/Library/Application Support/MbetterClient/ # macOS
%APPDATA%\MbetterClient\ # Windows
├── mbetterclient.db # SQLite database
├── config/
│ ├── app.json # Application settings
│ ├── api_endpoints.json # API client configuration
│ └── templates.json # Overlay template settings
└── logs/
├── app.log # Application logs
├── web.log # Web dashboard logs
└── api.log # API client logs
```
## Configuration
### Application Settings
Edit configuration through the web dashboard or modify JSON files directly:
#### app.json
```json
{
"web": {
"host": "localhost",
"port": 5000,
"secret_key": "your-secret-key",
"jwt_secret_key": "jwt-secret-key",
"jwt_expiration_hours": 24,
"session_timeout_hours": 8
},
"qt": {
"fullscreen": true,
"default_template": "news_template",
"video_directory": "/path/to/videos",
"supported_formats": ["mp4", "avi", "mov", "mkv"]
},
"api": {
"max_retries": 3,
"retry_backoff": 2,
"default_timeout": 30,
"max_consecutive_failures": 5,
"user_agent": "MbetterClient/1.0.0"
},
"logging": {
"level": "INFO",
"max_file_size": "10MB",
"backup_count": 5
}
}
```
#### api_endpoints.json
```json
{
"news_api": {
"url": "https://newsapi.org/v2/top-headlines",
"method": "GET",
"headers": {
"X-API-Key": "your-api-key"
},
"params": {
"country": "us",
"pageSize": 5
},
"interval": 300,
"enabled": true,
"timeout": 30,
"retry_attempts": 3,
"response_handler": "news"
},
"weather_api": {
"url": "https://api.openweathermap.org/data/2.5/weather",
"method": "GET",
"params": {
"q": "New York",
"appid": "your-api-key",
"units": "metric"
},
"interval": 1800,
"enabled": false,
"timeout": 15,
"response_handler": "default"
}
}
```
### Environment Variables
Create a `.env` file in the project root:
```bash
# Web Dashboard Configuration
WEB_HOST=0.0.0.0
WEB_PORT=5000
SECRET_KEY=your-very-secret-key-here
JWT_SECRET_KEY=your-jwt-secret-key-here
# Database
DATABASE_PATH=./data/mbetterclient.db
# Application Settings
LOG_LEVEL=INFO
DEBUG=False
FULLSCREEN=True
# API Client
API_CLIENT_TIMEOUT=30
API_CLIENT_RETRIES=3
# Development
FLASK_ENV=production
FLASK_DEBUG=False
```
## Usage Guide
### Command Line Usage
```bash
# Basic usage
python main.py
# Available options
python main.py [OPTIONS]
Options:
--web-host TEXT Web interface host [default: localhost]
--web-port INTEGER Web interface port [default: 5000]
--fullscreen Start video player in fullscreen mode
--no-fullscreen Start video player in windowed mode
--no-qt Disable PyQt video player
--no-web Disable web dashboard
--no-api Disable API client
--database-path TEXT Custom database path
--log-level TEXT Logging level [default: INFO]
--config-dir TEXT Custom configuration directory
--help Show this message and exit
```
### Web Dashboard Usage
#### Login and Authentication
1. Navigate to `http://localhost:5000`
2. Login with default credentials:
- Username: `admin`
- Password: `admin`
3. **Important**: Change default password immediately
#### Dashboard Overview
- **System Status**: Real-time component health monitoring
- **Video Control**: Play, pause, stop video with template selection
- **Quick Actions**: Common operations like starting playback or updating overlays
#### Video Control Panel
- **File Selection**: Choose video files from local filesystem
- **Template Selection**: Choose from news, sports, or simple templates
- **Overlay Data**: Configure dynamic text and data for overlays
- **Playback Controls**: Standard video controls with fullscreen support
#### API Token Management
1. Navigate to "API Tokens" section
2. Click "Create New Token"
3. Provide descriptive name and expiration time
4. Copy generated token (shown only once)
5. Use token for API authentication
#### User Management (Admin Only)
- **Add Users**: Create new user accounts with role assignment
- **Manage Permissions**: Set admin or regular user privileges
- **Password Reset**: Reset user passwords
- **User Activity**: View login history and token usage
### Video Player Usage
#### Keyboard Shortcuts
- `Space`: Play/Pause toggle
- `F11`: Toggle fullscreen mode
- `Esc`: Exit fullscreen mode
- `←` / `→`: Seek backward/forward (10 seconds)
- `↑` / `↓`: Volume up/down
- `M`: Mute/unmute
- `R`: Reset video position
- `Q`: Quit application
#### Overlay Templates
**News Template**:
- Scrolling ticker text at bottom
- Breaking news headline
- Logo display
- Timestamp
**Sports Template**:
- Team names and scores
- Game status and time
- League/tournament information
- Statistics display
**Simple Template**:
- Title and subtitle text
- Basic text overlay
- Customizable positioning
### API Client Configuration
#### Adding New Endpoints
1. Access "Configuration" → "API Endpoints" (Admin only)
2. Click "Add Endpoint"
3. Configure:
- **Name**: Unique identifier
- **URL**: Full API endpoint URL
- **Method**: HTTP method (GET, POST, etc.)
- **Headers**: Authentication and custom headers
- **Parameters**: Query parameters or request body
- **Interval**: Request frequency in seconds
- **Response Handler**: Data processing method
#### Response Handlers
- **default**: Basic JSON response processing
- **news**: Extracts headlines and ticker text for news overlays
- **sports**: Processes game scores and team information
- **custom**: User-defined processing logic
## API Reference
### Authentication
All API endpoints require authentication via Bearer token.
#### Get Access Token
```http
POST /auth/token
Content-Type: application/json
{
"username": "admin",
"password": "admin"
}
```
**Response:**
```json
{
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"token_type": "bearer",
"user": {
"id": 1,
"username": "admin",
"is_admin": true
}
}
```
### System Status
#### Get System Status
```http
GET /api/status
Authorization: Bearer <token>
```
**Response:**
```json
{
"status": "online",
"timestamp": "2025-01-19T10:00:00.000Z",
"uptime": 3600,
"components": {
"qt_player": "running",
"web_dashboard": "running",
"api_client": "running"
},
"config": {
"valid": true
},
"database": {
"connected": true
}
}
```
### Video Control
#### Play Video
```http
POST /api/video/control
Authorization: Bearer <token>
Content-Type: application/json
{
"action": "play",
"file_path": "/path/to/video.mp4",
"template": "news_template",
"overlay_data": {
"headline": "Breaking News",
"ticker_text": "This is a breaking news update"
}
}
```
#### Pause Video
```http
POST /api/video/control
Authorization: Bearer <token>
Content-Type: application/json
{
"action": "pause"
}
```
#### Get Video Status
```http
GET /api/video/status
Authorization: Bearer <token>
```
**Response:**
```json
{
"player_status": "playing",
"current_file": "/path/to/video.mp4",
"current_template": "news_template",
"position": 45.2,
"duration": 120.0,
"volume": 80,
"fullscreen": true
}
```
### Overlay Management
#### Update Overlay
```http
POST /api/overlay
Authorization: Bearer <token>
Content-Type: application/json
{
"template": "news_template",
"data": {
"headline": "Updated Headline",
"ticker_text": "New ticker information",
"logo_url": "https://example.com/logo.png"
}
}
```
#### Get Available Templates
```http
GET /api/templates
Authorization: Bearer <token>
```
**Response:**
```json
{
"templates": {
"news_template": {
"name": "News Template",
"description": "Breaking news with scrolling text",
"fields": ["headline", "ticker_text", "logo_url"]
},
"sports_template": {
"name": "Sports Template",
"description": "Sports scores and updates",
"fields": ["team1", "team2", "score1", "score2", "event"]
}
}
}
```
### Configuration Management
#### Get Configuration
```http
GET /api/config?section=web
Authorization: Bearer <token>
```
#### Update Configuration
```http
POST /api/config
Authorization: Bearer <token>
Content-Type: application/json
{
"section": "api_endpoints",
"config": {
"news_api": {
"enabled": true,
"interval": 600
}
}
}
```
### User Management
#### List Users (Admin Only)
```http
GET /api/users
Authorization: Bearer <token>
```
#### Create User (Admin Only)
```http
POST /api/users
Authorization: Bearer <token>
Content-Type: application/json
{
"username": "newuser",
"email": "user@example.com",
"password": "securepassword",
"is_admin": false
}
```
### Token Management
#### List API Tokens
```http
GET /api/tokens
Authorization: Bearer <token>
```
#### Create API Token
```http
POST /api/tokens
Authorization: Bearer <token>
Content-Type: application/json
{
"name": "My API Token",
"expires_hours": 8760
}
```
#### Revoke API Token
```http
DELETE /api/tokens/{token_id}
Authorization: Bearer <token>
```
## Development Guide
### Setting Up Development Environment
1. **Clone Repository**
```bash
git clone https://git.nexlab.net/mbetter/mbetterc.git
cd mbetterc
```
2. **Create Virtual Environment**
```bash
python -m venv venv
source venv/bin/activate # Linux/macOS
# or
venv\Scripts\activate # Windows
```
3. **Install Development Dependencies**
```bash
pip install -r requirements.txt
pip install pytest pytest-qt black pylint mypy
```
4. **Run Tests**
```bash
pytest tests/
```
### Code Style and Quality
#### Formatting with Black
```bash
# Format all code
black mbetterclient/
# Check formatting
black --check mbetterclient/
```
#### Linting with Pylint
```bash
# Lint all modules
pylint mbetterclient/
# Lint specific module
pylint mbetterclient/qt_player/
```
#### Type Checking with MyPy
```bash
# Type check
mypy mbetterclient/
```
### Testing
#### Running Tests
```bash
# Run all tests
pytest
# Run with coverage
pytest --cov=mbetterclient
# Run specific test file
pytest tests/test_message_bus.py
# Run PyQt tests (requires X server or Xvfb on Linux)
pytest tests/test_qt_player.py
```
#### Writing Tests
Example test structure:
```python
import pytest
from mbetterclient.core.message_bus import MessageBus, MessageType
class TestMessageBus:
def setup_method(self):
self.message_bus = MessageBus()
def test_component_registration(self):
queue = self.message_bus.register_component("test_component")
assert queue is not None
assert "test_component" in self.message_bus._queues
def test_message_publishing(self):
self.message_bus.register_component("sender")
self.message_bus.register_component("receiver")
message = Message(
type=MessageType.VIDEO_PLAY,
sender="sender",
recipient="receiver",
data={"file_path": "test.mp4"}
)
result = self.message_bus.publish(message)
assert result is True
```
### Adding New Features
#### Creating Custom Templates
1. **Define Template Class**
```python
# mbetterclient/qt_player/templates.py
class CustomTemplate(OverlayTemplate):
def __init__(self):
super().__init__("custom_template", "Custom Template")
self.fields = ["title", "content", "color"]
def render(self, painter, size, data):
# Custom rendering logic
painter.setPen(QColor(data.get("color", "#FFFFFF")))
painter.drawText(10, 30, data.get("title", ""))
painter.drawText(10, 60, data.get("content", ""))
```
2. **Register Template**
```python
# In template manager initialization
template_manager.register_template(CustomTemplate())
```
#### Adding API Response Handlers
```python
# mbetterclient/api_client/client.py
class CustomResponseHandler(ResponseHandler):
def handle_response(self, endpoint: APIEndpoint, response: requests.Response):
try:
data = response.json()
processed_data = {
'source': endpoint.name,
'timestamp': datetime.utcnow().isoformat(),
'custom_field': data.get('important_data')
}
return processed_data
except Exception as e:
return self.handle_error(endpoint, e)
```
#### Extending Web Dashboard
1. **Add New Route**
```python
# mbetterclient/web_dashboard/routes.py
@main_bp.route('/custom')
@login_required
def custom_page():
return render_template('custom.html')
```
2. **Create Template**
```html
<!-- mbetterclient/web_dashboard/templates/custom.html -->
{% extends "base.html" %}
{% block content %}
<h1>Custom Page</h1>
<p>Custom functionality here</p>
{% endblock %}
```
### Message Bus System
#### Message Types
Define new message types in `core/message_bus.py`:
```python
class MessageType(Enum):
# Add new message type
CUSTOM_ACTION = "CUSTOM_ACTION"
```
#### Message Builders
Add helper methods for creating messages:
```python
@staticmethod
def custom_action(sender: str, action_data: Dict[str, Any]) -> Message:
return Message(
type=MessageType.CUSTOM_ACTION,
sender=sender,
data=action_data
)
```
#### Message Handlers
Subscribe to messages in component initialization:
```python
def initialize(self) -> bool:
self.message_bus.subscribe(
self.name,
MessageType.CUSTOM_ACTION,
self._handle_custom_action
)
return True
def _handle_custom_action(self, message: Message):
action_data = message.data
# Process custom action
```
## Troubleshooting
### Common Issues
#### Application Won't Start
**Symptoms**: Python errors on startup, missing dependencies
**Solutions**:
1. Verify Python version: `python --version` (must be 3.8+)
2. Check virtual environment activation
3. Reinstall dependencies: `pip install -r requirements.txt --force-reinstall`
4. Check file permissions on configuration directory
#### Video Player Issues
**Symptoms**: Black screen, video won't play, no audio
**Solutions**:
1. Verify video file path and format
2. Check PyQt5 multimedia installation:
```bash
python -c "from PyQt5.QtMultimedia import QMediaPlayer; print('OK')"
```
3. Install system multimedia codecs
4. Try different video format (MP4 recommended)
5. Check file permissions and disk space
#### Web Dashboard Inaccessible
**Symptoms**: Connection refused, page won't load
**Solutions**:
1. Check if port 5000 is already in use:
```bash
# Linux/macOS
lsof -i :5000
# Windows
netstat -an | findstr :5000
```
2. Try different port: `python main.py --web-port 8080`
3. Check firewall settings
4. Verify Flask installation: `pip show Flask`
#### Database Errors
**Symptoms**: SQLite errors, configuration not saving
**Solutions**:
1. Check database file permissions
2. Verify disk space availability
3. Delete corrupted database (will recreate):
```bash
rm ~/.config/MbetterClient/mbetterclient.db
```
4. Check SQLite installation: `python -c "import sqlite3; print(sqlite3.sqlite_version)"`
#### API Client Not Working
**Symptoms**: External API requests failing, no data updates
**Solutions**:
1. Check internet connectivity
2. Verify API key and endpoint URL
3. Check API rate limits and quotas
4. Review application logs for specific errors
5. Test endpoint manually with curl:
```bash
curl -H "Authorization: Bearer your-token" https://api.example.com/endpoint
```
### Logging and Debugging
#### Enable Debug Logging
```bash
# Run with debug logging
python main.py --log-level DEBUG
# Or set environment variable
export LOG_LEVEL=DEBUG
python main.py
```
#### Log File Locations
- **Linux**: `~/.config/MbetterClient/logs/`
- **macOS**: `~/Library/Application Support/MbetterClient/logs/`
- **Windows**: `%APPDATA%\MbetterClient\logs\`
#### Useful Log Commands
```bash
# View recent logs
tail -f ~/.config/MbetterClient/logs/app.log
# Search for errors
grep ERROR ~/.config/MbetterClient/logs/app.log
# Filter by component
grep "qt_player" ~/.config/MbetterClient/logs/app.log
```
### Performance Optimization
#### Video Performance
- Use hardware-accelerated video formats (H.264)
- Reduce video resolution for older hardware
- Close unused applications to free memory
- Use SSD storage for video files
#### Web Dashboard Performance
- Increase status update intervals for slower systems
- Disable real-time features if not needed
- Use browser developer tools to identify bottlenecks
- Consider using reverse proxy (nginx) for production
#### API Client Performance
- Adjust request intervals based on API limits
- Implement request caching for static data
- Use compression for large responses
- Monitor network usage and optimize accordingly
### Build Issues
#### PyInstaller Problems
**Symptoms**: Build fails, missing modules, large executable size
**Solutions**:
1. Update PyInstaller: `pip install --upgrade pyinstaller`
2. Clear PyInstaller cache: `pyi-makespec --clean main.py`
3. Add missing modules to hiddenimports in build.py
4. Use UPX compression (if available): Set `upx=True` in build config
5. Exclude unnecessary modules in build.py
#### Platform-Specific Issues
**Windows**:
- Install Visual C++ Redistributable
- Add Windows Defender exclusion for build directory
- Use Windows-compatible paths in configuration
**macOS**:
- Install Xcode command line tools: `xcode-select --install`
- Code signing may be required for distribution
- Use .icns icon format
**Linux**:
- Install required system libraries: `apt-get install python3-pyqt5.qtmultimedia`
- Ensure Qt libraries are available on target systems
- Use AppImage for better compatibility
## Advanced Topics
### Custom Response Handlers
Create specialized handlers for different API formats:
```python
class WeatherResponseHandler(ResponseHandler):
def handle_response(self, endpoint: APIEndpoint, response: requests.Response):
try:
data = response.json()
return {
'source': 'weather',
'temperature': data['main']['temp'],
'condition': data['weather'][0]['description'],
'location': data['name'],
'overlay_text': f"{data['name']}: {data['main']['temp']}°C, {data['weather'][0]['description']}"
}
except Exception as e:
logger.error(f"Weather processing error: {e}")
return self.handle_error(endpoint, e)
```
### Database Schema Extensions
Add custom tables for application-specific data:
```python
# In database/models.py
class CustomData(Base):
__tablename__ = 'custom_data'
id = Column(Integer, primary_key=True)
name = Column(String(100), nullable=False)
value = Column(Text)
created_at = Column(DateTime, default=datetime.utcnow)
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'value': self.value,
'created_at': self.created_at.isoformat()
}
```
### Plugin System Architecture
While not fully implemented, the architecture supports plugin extensions:
```python
# Future plugin interface
class MbetterPlugin:
def __init__(self, name: str):
self.name = name
def initialize(self, app_context):
pass
def get_routes(self):
return []
def get_message_handlers(self):
return {}
def shutdown(self):
pass
```
### Security Considerations
#### Web Dashboard Security
- Change default passwords immediately
- Use HTTPS in production environments
- Implement rate limiting for API endpoints
- Regular token rotation
- Secure configuration file permissions
#### API Security
- Use strong JWT secrets
- Implement token expiration and refresh
- Validate all input data
- Log authentication attempts
- Monitor for unusual activity patterns
#### File System Security
- Restrict video file access paths
- Validate file types and sizes
- Use sandboxed directories
- Regular backup of configuration and database
### Performance Monitoring
#### Built-in Metrics
Access metrics through the web dashboard or API:
```python
# Get system statistics
stats = {
'memory_usage': psutil.Process().memory_info().rss / 1024 / 1024, # MB
'cpu_percent': psutil.Process().cpu_percent(),
'thread_count': threading.active_count(),
'uptime': time.time() - start_time
}
```
#### External Monitoring
Integrate with monitoring systems:
```python
# Example: Send metrics to external system
def send_metrics():
metrics = get_system_metrics()
requests.post('https://monitoring.example.com/metrics', json=metrics)
```
### Deployment Strategies
#### Production Deployment
1. **Use Process Manager**
```bash
# With systemd
sudo systemctl enable mbetterclient
sudo systemctl start mbetterclient
```
2. **Reverse Proxy Setup**
```nginx
server {
listen 80;
server_name mbetterclient.example.com;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
3. **SSL Configuration**
```bash
# With Let's Encrypt
certbot --nginx -d mbetterclient.example.com
```
#### Docker Deployment
```dockerfile
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["python", "main.py", "--web-host", "0.0.0.0"]
```
This comprehensive documentation covers all aspects of MbetterClient from installation to advanced deployment scenarios. For additional support, please refer to the project repository or contact the development team.
\ No newline at end of file
MIT License
Copyright (c) 2025 MBetter Project
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
\ No newline at end of file
# MbetterClient
A cross-platform multimedia client application with video playback, web dashboard, and REST API integration.
## Features
- **PyQt Video Player**: Fullscreen video playback with customizable overlay templates
- **Web Dashboard**: Authentication, user management, and configuration interface
- **REST API Client**: Configurable external API integration with automatic retry
- **Multi-threaded Architecture**: Four threads with Queue-based message passing
- **Offline Capability**: Works seamlessly without internet connectivity
- **Cross-Platform**: Supports Windows, Linux, and macOS
- **Single Executable**: Built with PyInstaller for easy deployment
## Architecture
The application consists of four main threads:
1. **PyQt Thread**: Video player with overlay rendering
2. **Web Dashboard Thread**: Flask-based web interface
3. **REST API Client Thread**: External API communication
4. **Main Loop Thread**: Inter-thread message coordination
## Quick Start
### Installation
```bash
# Clone the repository
git clone https://git.nexlab.net/mbetter/mbetterc.git
cd mbetterc
# Create virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
```
### Running the Application
```bash
# Run in fullscreen mode (default)
python main.py
# Run in windowed mode
python main.py --no-fullscreen
# Show help
python main.py --help
```
### Building Executable
```bash
# Build for current platform
python build.py
# The executable will be in dist/ directory
```
## Configuration
Configuration is stored in SQLite database with automatic versioning. Access the web dashboard at `http://localhost:5001` (default) to configure:
- Video overlay templates
- REST API endpoints and tokens
- User authentication
- System settings
## Development
### Project Structure
```
mbetterc/
├── main.py # Application entry point
├── requirements.txt # Python dependencies
├── build.py # PyInstaller build script
├── mbetterclient/ # Main application package
│ ├── __init__.py
│ ├── config/ # Configuration management
│ ├── database/ # SQLite database and models
│ ├── qt_player/ # PyQt video player
│ ├── web_dashboard/ # Flask web interface
│ ├── api_client/ # REST API client
│ ├── core/ # Main loop and message handling
│ └── utils/ # Utility functions
├── assets/ # Static assets (images, templates)
├── templates/ # Video overlay templates
├── tests/ # Unit tests
└── docs/ # Documentation
```
### Message System
Threads communicate via Python Queues with structured messages:
```python
{
"type": "VIDEO_PLAY",
"data": {
"file_path": "/path/to/video.mp4",
"template": "news_template",
"overlay_data": {...}
},
"timestamp": 1234567890.123,
"sender": "web_dashboard"
}
```
## API Documentation
### Web Dashboard API
- `POST /auth/login` - User authentication
- `GET /api/tokens` - List JWT tokens
- `POST /api/tokens` - Create new token
- `DELETE /api/tokens/{id}` - Delete token
- `GET /api/config` - Get configuration
- `PUT /api/config` - Update configuration
### Message Types
#### Video Control
- `VIDEO_PLAY` - Start video playback
- `VIDEO_PAUSE` - Pause video
- `VIDEO_STOP` - Stop video
- `VIDEO_PROGRESS` - Progress update from player
#### API Client
- `API_REQUEST` - Make external API request
- `API_RESPONSE` - Response from external API
- `API_ERROR` - API request error
#### Configuration
- `CONFIG_UPDATE` - Configuration changed
- `TEMPLATE_CHANGE` - Video template changed
## License
Copyright (c) 2025 MBetter Project. All rights reserved.
## Support
For support and documentation, visit the project repository.
\ No newline at end of file
# This is a placeholder for the boxing athletes image
# In a real implementation, this would be a PNG image file
# For now, we'll create a simple text file to represent the asset
PLACEHOLDER: Boxing Athletes Image
- Two black athletes boxing
- Size: 110x110 pixels
- Format: PNG with transparency
- Used in news template overlay
\ No newline at end of file
@echo off
REM Build script for Windows
echo 🚀 MbetterClient Build Script
echo =============================
REM Check if Python is available
python --version >nul 2>&1
if %errorlevel% neq 0 (
echo ❌ Python is required but not installed.
pause
exit /b 1
)
REM Check if virtual environment exists
if not exist "venv" (
echo ⚠️ Virtual environment not found. Creating one...
python -m venv venv
)
REM Activate virtual environment
echo 🔧 Activating virtual environment...
call venv\Scripts\activate.bat
REM Install/upgrade dependencies
echo 📦 Installing dependencies...
python -m pip install --upgrade pip
pip install -r requirements.txt
REM Run the build script
echo 🔨 Starting build process...
python build.py
echo ✅ Build script completed!
pause
\ No newline at end of file
#!/usr/bin/env python3
"""
Build script for creating cross-platform executables of MbetterClient using PyInstaller
"""
import os
import sys
import shutil
import platform
import subprocess
from pathlib import Path
from typing import List, Dict, Any
# Build configuration
BUILD_CONFIG = {
'app_name': 'MbetterClient',
'app_version': '1.0.0',
'description': 'Cross-platform multimedia client application',
'author': 'MBetter Team',
'entry_point': 'main.py',
'icon_file': 'assets/icon.ico' if platform.system() == 'Windows' else 'assets/icon.png',
'console': False, # Set to True for debugging
'one_file': True, # Create single executable
'clean_build': True,
'upx': False, # UPX compression (set to True if UPX is available)
}
# Platform-specific configurations
PLATFORM_CONFIG = {
'Windows': {
'executable_name': f"{BUILD_CONFIG['app_name']}.exe",
'icon_ext': '.ico',
'additional_paths': [],
'exclude_modules': ['tkinter', 'unittest', 'doctest', 'pdb'],
},
'Darwin': { # macOS
'executable_name': BUILD_CONFIG['app_name'],
'icon_ext': '.icns',
'additional_paths': [],
'exclude_modules': ['tkinter', 'unittest', 'doctest', 'pdb'],
},
'Linux': {
'executable_name': BUILD_CONFIG['app_name'],
'icon_ext': '.png',
'additional_paths': [],
'exclude_modules': ['tkinter', 'unittest', 'doctest', 'pdb'],
}
}
def get_project_root() -> Path:
"""Get the project root directory"""
return Path(__file__).parent
def get_build_dir() -> Path:
"""Get the build directory"""
return get_project_root() / 'build'
def get_dist_dir() -> Path:
"""Get the distribution directory"""
return get_project_root() / 'dist'
def clean_build_directories():
"""Clean previous build artifacts"""
print("🧹 Cleaning build directories...")
dirs_to_clean = [get_build_dir(), get_dist_dir()]
for dir_path in dirs_to_clean:
if dir_path.exists():
shutil.rmtree(dir_path)
print(f" Removed: {dir_path}")
# Remove spec file if it exists
spec_file = get_project_root() / f"{BUILD_CONFIG['app_name']}.spec"
if spec_file.exists():
spec_file.unlink()
print(f" Removed: {spec_file}")
def collect_data_files() -> List[tuple]:
"""Collect data files that need to be included in the build"""
project_root = get_project_root()
data_files = []
# Include assets directory
assets_dir = project_root / 'assets'
if assets_dir.exists():
for file_path in assets_dir.rglob('*'):
if file_path.is_file():
relative_path = file_path.relative_to(project_root)
data_files.append((str(file_path), str(relative_path.parent)))
# Include web dashboard templates and static files
web_templates = project_root / 'mbetterclient' / 'web_dashboard' / 'templates'
if web_templates.exists():
for file_path in web_templates.rglob('*'):
if file_path.is_file():
relative_path = file_path.relative_to(project_root)
data_files.append((str(file_path), str(relative_path.parent)))
web_static = project_root / 'mbetterclient' / 'web_dashboard' / 'static'
if web_static.exists():
for file_path in web_static.rglob('*'):
if file_path.is_file():
relative_path = file_path.relative_to(project_root)
data_files.append((str(file_path), str(relative_path.parent)))
return data_files
def collect_hidden_imports() -> List[str]:
"""Collect hidden imports that PyInstaller might miss"""
return [
# PyQt5 modules
'PyQt5.QtCore',
'PyQt5.QtGui',
'PyQt5.QtWidgets',
'PyQt5.QtMultimedia',
'PyQt5.QtMultimediaWidgets',
# Flask and web dependencies
'flask',
'flask_login',
'flask_jwt_extended',
'werkzeug.security',
'jinja2',
# SQLAlchemy
'sqlalchemy',
'sqlalchemy.sql.default_comparator',
# Requests and HTTP
'requests',
'urllib3',
# Other dependencies
'packaging',
'pkg_resources',
]
def get_platform_config() -> Dict[str, Any]:
"""Get platform-specific configuration"""
system = platform.system()
return PLATFORM_CONFIG.get(system, PLATFORM_CONFIG['Linux'])
def create_icon_file():
"""Create or copy icon file for the platform"""
project_root = get_project_root()
platform_config = get_platform_config()
icon_source = project_root / 'assets' / 'icon.png' # Default source
icon_target = project_root / 'assets' / f"icon{platform_config['icon_ext']}"
# Create assets directory if it doesn't exist
(project_root / 'assets').mkdir(exist_ok=True)
# Create a simple icon if none exists
if not icon_source.exists() and not icon_target.exists():
print("📦 Creating default icon...")
try:
# Try to create a simple icon using PIL if available
from PIL import Image, ImageDraw
# Create a simple 256x256 icon
img = Image.new('RGBA', (256, 256), (70, 130, 180, 255)) # Steel blue
draw = ImageDraw.Draw(img)
# Draw a simple play button
points = [(80, 60), (180, 128), (80, 196)]
draw.polygon(points, fill=(255, 255, 255, 255))
# Save as PNG
img.save(icon_source)
print(f" Created: {icon_source}")
except ImportError:
# Create a simple text-based icon file (fallback)
with open(icon_source, 'wb') as f:
# This is just a placeholder - in a real scenario you'd have a proper icon
f.write(b'')
print(f" Created placeholder: {icon_source}")
# Copy to target format if needed
if icon_source.exists() and not icon_target.exists() and icon_source != icon_target:
shutil.copy2(icon_source, icon_target)
return icon_target if icon_target.exists() else None
def generate_spec_file():
"""Generate PyInstaller spec file"""
project_root = get_project_root()
platform_config = get_platform_config()
# Collect files and imports
data_files = collect_data_files()
hidden_imports = collect_hidden_imports()
icon_file = create_icon_file()
# Build pathex (additional paths for imports)
pathex = [str(project_root)] + platform_config.get('additional_paths', [])
spec_content = f'''# -*- mode: python ; coding: utf-8 -*-
block_cipher = None
a = Analysis(
['{BUILD_CONFIG['entry_point']}'],
pathex={pathex!r},
binaries=[],
datas={data_files!r},
hiddenimports={hidden_imports!r},
hookspath=[],
hooksconfig={{}},
runtime_hooks=[],
excludes={platform_config.get('exclude_modules', [])!r},
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='{platform_config['executable_name']}',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx={BUILD_CONFIG['upx']},
upx_exclude=[],
runtime_tmpdir=None,
console={BUILD_CONFIG['console']},
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
version='{BUILD_CONFIG['app_version']}',
description='{BUILD_CONFIG['description']}',
icon={repr(str(icon_file)) if icon_file else None},
)
'''
# Add macOS app bundle configuration if on macOS
if platform.system() == 'Darwin':
spec_content += f'''
app = BUNDLE(
exe,
name='{BUILD_CONFIG['app_name']}.app',
icon={repr(str(icon_file)) if icon_file else None},
bundle_identifier='net.nexlab.mbetterclient',
version='{BUILD_CONFIG['app_version']}',
info_plist={{
'CFBundleShortVersionString': '{BUILD_CONFIG['app_version']}',
'CFBundleVersion': '{BUILD_CONFIG['app_version']}',
'NSHighResolutionCapable': 'True',
'NSRequiresAquaSystemAppearance': 'False',
}},
)
'''
spec_file_path = project_root / f"{BUILD_CONFIG['app_name']}.spec"
with open(spec_file_path, 'w', encoding='utf-8') as f:
f.write(spec_content)
print(f"📝 Generated spec file: {spec_file_path}")
return spec_file_path
def check_dependencies():
"""Check if all required dependencies are available"""
print("🔍 Checking dependencies...")
required_packages = ['PyInstaller', 'PyQt5']
missing_packages = []
for package in required_packages:
try:
__import__(package.lower().replace('-', '_'))
print(f" ✓ {package}")
except ImportError:
missing_packages.append(package)
print(f" ✗ {package}")
if missing_packages:
print(f"\n❌ Missing dependencies: {', '.join(missing_packages)}")
print("Please install them using:")
print(f" pip install {' '.join(missing_packages)}")
return False
return True
def run_pyinstaller(spec_file_path: Path):
"""Run PyInstaller with the generated spec file"""
print("🔨 Running PyInstaller...")
cmd = [
sys.executable, '-m', 'PyInstaller',
'--clean' if BUILD_CONFIG['clean_build'] else '--noconfirm',
str(spec_file_path)
]
print(f"Command: {' '.join(cmd)}")
try:
result = subprocess.run(cmd, check=True, capture_output=False)
print("✅ Build completed successfully!")
return True
except subprocess.CalledProcessError as e:
print(f"❌ Build failed with exit code {e.returncode}")
return False
def post_build_tasks():
"""Perform post-build tasks"""
print("📦 Performing post-build tasks...")
dist_dir = get_dist_dir()
platform_config = get_platform_config()
if not dist_dir.exists():
print(" No dist directory found")
return
# Find the created executable
executable_path = None
for item in dist_dir.iterdir():
if item.is_file() and item.name == platform_config['executable_name']:
executable_path = item
break
elif item.is_dir() and item.name.endswith('.app'): # macOS app bundle
executable_path = item
break
if executable_path:
size_mb = get_file_size_mb(executable_path)
print(f" 📱 Created: {executable_path} ({size_mb:.1f} MB)")
# Make executable on Unix systems
if platform.system() in ['Linux', 'Darwin'] and executable_path.is_file():
os.chmod(executable_path, 0o755)
print(" 🔧 Made executable")
# Create distribution package
create_distribution_package(dist_dir)
def get_file_size_mb(path: Path) -> float:
"""Get file or directory size in MB"""
if path.is_file():
return path.stat().st_size / (1024 * 1024)
elif path.is_dir():
total_size = sum(f.stat().st_size for f in path.rglob('*') if f.is_file())
return total_size / (1024 * 1024)
return 0.0
def create_distribution_package(dist_dir: Path):
"""Create a distribution package"""
print("📦 Creating distribution package...")
project_root = get_project_root()
platform_name = platform.system().lower()
arch = platform.machine().lower()
package_name = f"{BUILD_CONFIG['app_name']}-{BUILD_CONFIG['app_version']}-{platform_name}-{arch}"
package_dir = project_root / 'packages'
package_dir.mkdir(exist_ok=True)
# Copy executable and create package
if platform.system() == 'Windows':
# Create ZIP package for Windows
package_file = package_dir / f"{package_name}.zip"
shutil.make_archive(str(package_file.with_suffix('')), 'zip', str(dist_dir))
print(f" 📦 Created: {package_file}")
else:
# Create tar.gz package for Unix systems
package_file = package_dir / f"{package_name}.tar.gz"
shutil.make_archive(str(package_file.with_suffix('').with_suffix('')), 'gztar', str(dist_dir))
print(f" 📦 Created: {package_file}")
# Create README for distribution
readme_content = f"""# {BUILD_CONFIG['app_name']} v{BUILD_CONFIG['app_version']}
{BUILD_CONFIG['description']}
## Installation
1. Extract this package to your desired location
2. Run the executable file
3. The application will create necessary configuration files on first run
## System Requirements
- **Operating System**: {platform.system()} {platform.release()}
- **Architecture**: {platform.machine()}
- **Memory**: 512 MB RAM minimum, 1 GB recommended
- **Disk Space**: 100 MB free space
## Configuration
The application stores its configuration and database in:
- **Windows**: `%APPDATA%\\{BUILD_CONFIG['app_name']}`
- **macOS**: `~/Library/Application Support/{BUILD_CONFIG['app_name']}`
- **Linux**: `~/.config/{BUILD_CONFIG['app_name']}`
## Web Interface
By default, the web interface is available at: http://localhost:5000
Default login credentials:
- Username: admin
- Password: admin
**Please change the default password after first login.**
## Support
For support and documentation, please visit: https://git.nexlab.net/mbetter/mbetterc
## Version Information
- Version: {BUILD_CONFIG['app_version']}
- Build Date: {platform.node()}
- Platform: {platform.platform()}
"""
readme_file = package_dir / f"{package_name}-README.txt"
with open(readme_file, 'w', encoding='utf-8') as f:
f.write(readme_content)
print(f" 📄 Created: {readme_file}")
def main():
"""Main build process"""
print(f"🚀 Building {BUILD_CONFIG['app_name']} v{BUILD_CONFIG['app_version']}")
print(f"Platform: {platform.system()} {platform.release()} ({platform.machine()})")
print()
# Check dependencies
if not check_dependencies():
sys.exit(1)
# Clean build directories
if BUILD_CONFIG['clean_build']:
clean_build_directories()
# Generate spec file
spec_file_path = generate_spec_file()
# Run PyInstaller
if not run_pyinstaller(spec_file_path):
sys.exit(1)
# Post-build tasks
post_build_tasks()
print()
print("🎉 Build process completed successfully!")
print(f"📁 Distribution files are in: {get_dist_dir()}")
print(f"📦 Packages are in: {get_project_root() / 'packages'}")
if __name__ == '__main__':
main()
\ No newline at end of file
#!/bin/bash
# Build script for Unix systems (Linux/macOS)
echo "🚀 MbetterClient Build Script"
echo "============================="
# Check if Python 3 is available
if ! command -v python3 &> /dev/null; then
echo "❌ Python 3 is required but not installed."
exit 1
fi
# Check if virtual environment exists
if [ ! -d "venv" ]; then
echo "⚠️ Virtual environment not found. Creating one..."
python3 -m venv venv
fi
# Activate virtual environment
echo "🔧 Activating virtual environment..."
source venv/bin/activate
# Install/upgrade dependencies
echo "📦 Installing dependencies..."
pip install --upgrade pip
pip install -r requirements.txt
# Run the build script
echo "🔨 Starting build process..."
python3 build.py
echo "✅ Build script completed!"
\ No newline at end of file
#!/usr/bin/env python3
"""
MbetterClient - Cross-platform multimedia client application
Entry point for the application with command line argument handling
"""
import sys
import os
import argparse
import signal
import logging
from pathlib import Path
# Add the project root to Python path
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
from mbetterclient.core.application import MbetterClientApplication
from mbetterclient.utils.logger import setup_logging
from mbetterclient.config.settings import AppSettings
def setup_signal_handlers(app):
"""Setup signal handlers for graceful shutdown"""
def signal_handler(signum, frame):
logging.info(f"Received signal {signum}, initiating shutdown...")
if app:
app.shutdown()
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# Windows doesn't have SIGHUP
if hasattr(signal, 'SIGHUP'):
signal.signal(signal.SIGHUP, signal_handler)
def parse_arguments():
"""Parse command line arguments"""
parser = argparse.ArgumentParser(
description='MbetterClient - Cross-platform multimedia client',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python main.py # Run in fullscreen mode
python main.py --no-fullscreen # Run in windowed mode
python main.py --web-port 8080 # Custom web dashboard port
python main.py --debug # Enable debug logging
"""
)
# Display options
parser.add_argument(
'--no-fullscreen',
action='store_true',
help='Run video player in windowed mode instead of fullscreen'
)
parser.add_argument(
'--web-port',
type=int,
default=5001,
help='Port for web dashboard (default: 5001)'
)
parser.add_argument(
'--web-host',
type=str,
default='127.0.0.1',
help='Host for web dashboard (default: 127.0.0.1)'
)
# Database options
parser.add_argument(
'--db-path',
type=str,
default=None,
help='Custom database file path (default: data/mbetterclient.db)'
)
# Logging options
parser.add_argument(
'--debug',
action='store_true',
help='Enable debug logging'
)
parser.add_argument(
'--log-file',
type=str,
default=None,
help='Log file path (default: logs/mbetterclient.log)'
)
# Development options
parser.add_argument(
'--dev-mode',
action='store_true',
help='Enable development mode with additional debugging'
)
parser.add_argument(
'--no-qt',
action='store_true',
help='Disable PyQt interface (web dashboard only)'
)
parser.add_argument(
'--no-web',
action='store_true',
help='Disable web dashboard (PyQt interface only)'
)
parser.add_argument(
'--version',
action='version',
version='MbetterClient 1.0.0'
)
return parser.parse_args()
def validate_arguments(args):
"""Validate command line arguments"""
if args.no_qt and args.no_web:
print("Error: Cannot disable both Qt and web interfaces")
sys.exit(1)
if args.web_port < 1 or args.web_port > 65535:
print("Error: Web port must be between 1 and 65535")
sys.exit(1)
# Create necessary directories
project_root = Path(__file__).parent
# Data directory
data_dir = project_root / 'data'
data_dir.mkdir(exist_ok=True)
# Logs directory
logs_dir = project_root / 'logs'
logs_dir.mkdir(exist_ok=True)
# Assets directory
assets_dir = project_root / 'assets'
assets_dir.mkdir(exist_ok=True)
# Templates directory
templates_dir = project_root / 'templates'
templates_dir.mkdir(exist_ok=True)
def main():
"""Main entry point"""
try:
# Parse command line arguments
args = parse_arguments()
# Validate arguments and create directories
validate_arguments(args)
# Setup logging
log_level = logging.DEBUG if args.debug else logging.INFO
log_file = args.log_file or 'logs/mbetterclient.log'
logger = setup_logging(level=log_level, log_file=log_file)
logger.info("=" * 60)
logger.info("MbetterClient Starting")
logger.info("=" * 60)
logger.info(f"Arguments: {vars(args)}")
# Create application settings
settings = AppSettings()
settings.fullscreen = not args.no_fullscreen
settings.web_host = args.web_host
settings.web_port = args.web_port
settings.debug_mode = args.debug or args.dev_mode
settings.enable_qt = not args.no_qt
settings.enable_web = not args.no_web
if args.db_path:
settings.database_path = args.db_path
# Create and initialize application
app = MbetterClientApplication(settings)
# Setup signal handlers for graceful shutdown
setup_signal_handlers(app)
# Initialize application
logger.info("Initializing application components...")
if not app.initialize():
logger.error("Failed to initialize application")
sys.exit(1)
logger.info("Starting application...")
# Run the application (this blocks until shutdown)
exit_code = app.run()
logger.info("Application finished")
sys.exit(exit_code)
except KeyboardInterrupt:
print("\nInterrupted by user")
sys.exit(0)
except Exception as e:
print(f"Fatal error: {e}")
if args.debug if 'args' in locals() else False:
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()
\ No newline at end of file
"""
MbetterClient - Cross-platform multimedia client application
A multi-threaded application with video playback, web dashboard, and REST API integration.
"""
__version__ = "1.0.0"
__author__ = "MBetter Project"
__email__ = "dev@mbetter.net"
__description__ = "Cross-platform multimedia client with video overlay and web dashboard"
from .core.application import MbetterClientApplication
from .config.settings import AppSettings
__all__ = [
'MbetterClientApplication',
'AppSettings',
'__version__',
'__author__',
'__email__',
'__description__'
]
\ No newline at end of file
"""
REST API Client for MbetterClient
This module provides a threaded HTTP client for making configurable requests
to external APIs with proper error handling and retry logic.
"""
from .client import APIClient
__all__ = ['APIClient']
\ No newline at end of file
"""
REST API Client - Configurable HTTP client with retry logic and failure handling
"""
import time
import logging
import json
import threading
from datetime import datetime, timedelta
from typing import Dict, Any, Optional, List, Union
from urllib.parse import urljoin, urlparse
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from ..core.thread_manager import ThreadedComponent
from ..core.message_bus import MessageBus, Message, MessageType, MessageBuilder
from ..config.settings import APIClientConfig
from ..config.manager import ConfigManager
from ..database.manager import DatabaseManager
logger = logging.getLogger(__name__)
class APIEndpoint:
"""Configuration for a single API endpoint"""
def __init__(self, name: str, config: Dict[str, Any]):
self.name = name
self.url = config.get('url', '')
self.method = config.get('method', 'GET').upper()
self.headers = config.get('headers', {})
self.params = config.get('params', {})
self.data = config.get('data', {})
self.interval = config.get('interval', 300) # 5 minutes default
self.enabled = config.get('enabled', True)
self.timeout = config.get('timeout', 30)
self.retry_attempts = config.get('retry_attempts', 3)
self.retry_delay = config.get('retry_delay', 5)
self.auth = config.get('auth', None)
self.response_handler = config.get('response_handler', 'default')
# Runtime state
self.last_request = None
self.last_success = None
self.last_error = None
self.consecutive_failures = 0
self.total_requests = 0
self.successful_requests = 0
def should_execute(self) -> bool:
"""Check if endpoint should be executed based on interval"""
if not self.enabled:
return False
if self.last_request is None:
return True
next_execution = self.last_request + timedelta(seconds=self.interval)
return datetime.utcnow() >= next_execution
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization"""
return {
'name': self.name,
'url': self.url,
'method': self.method,
'headers': self.headers,
'params': self.params,
'data': self.data,
'interval': self.interval,
'enabled': self.enabled,
'timeout': self.timeout,
'retry_attempts': self.retry_attempts,
'retry_delay': self.retry_delay,
'auth': self.auth,
'response_handler': self.response_handler,
'last_request': self.last_request.isoformat() if self.last_request else None,
'last_success': self.last_success.isoformat() if self.last_success else None,
'last_error': self.last_error,
'consecutive_failures': self.consecutive_failures,
'total_requests': self.total_requests,
'successful_requests': self.successful_requests
}
class ResponseHandler:
"""Base class for response handlers"""
def handle_response(self, endpoint: APIEndpoint, response: requests.Response) -> Optional[Dict[str, Any]]:
"""Handle API response and return processed data"""
raise NotImplementedError
def handle_error(self, endpoint: APIEndpoint, error: Exception) -> Optional[Dict[str, Any]]:
"""Handle API error and return error data"""
return {
'error': str(error),
'endpoint': endpoint.name,
'timestamp': datetime.utcnow().isoformat()
}
class DefaultResponseHandler(ResponseHandler):
"""Default response handler - processes JSON responses"""
def handle_response(self, endpoint: APIEndpoint, response: requests.Response) -> Optional[Dict[str, Any]]:
try:
if response.headers.get('content-type', '').startswith('application/json'):
return response.json()
else:
return {
'status_code': response.status_code,
'content': response.text,
'headers': dict(response.headers)
}
except json.JSONDecodeError:
return {
'status_code': response.status_code,
'content': response.text,
'headers': dict(response.headers)
}
class NewsResponseHandler(ResponseHandler):
"""Response handler for news APIs - extracts headline and ticker data"""
def handle_response(self, endpoint: APIEndpoint, response: requests.Response) -> Optional[Dict[str, Any]]:
try:
data = response.json()
# Extract news data for overlay templates
processed_data = {
'source': endpoint.name,
'timestamp': datetime.utcnow().isoformat(),
'raw_data': data
}
# Try to extract common news fields
if 'articles' in data:
# NewsAPI format
articles = data['articles'][:5] # Top 5 articles
processed_data['headlines'] = [article.get('title', '') for article in articles]
processed_data['ticker_text'] = ' • '.join(processed_data['headlines'])
elif 'items' in data:
# RSS format
items = data['items'][:5]
processed_data['headlines'] = [item.get('title', '') for item in items]
processed_data['ticker_text'] = ' • '.join(processed_data['headlines'])
elif isinstance(data, list):
# Direct array format
processed_data['headlines'] = [item.get('title', '') for item in data[:5]]
processed_data['ticker_text'] = ' • '.join(processed_data['headlines'])
return processed_data
except (json.JSONDecodeError, KeyError, TypeError) as e:
logger.error(f"Failed to process news response: {e}")
return self.handle_error(endpoint, e)
class SportsResponseHandler(ResponseHandler):
"""Response handler for sports APIs - extracts scores and game data"""
def handle_response(self, endpoint: APIEndpoint, response: requests.Response) -> Optional[Dict[str, Any]]:
try:
data = response.json()
processed_data = {
'source': endpoint.name,
'timestamp': datetime.utcnow().isoformat(),
'raw_data': data
}
# Extract sports data
if 'games' in data:
# Generic sports API format
games = data['games'][:3] # Top 3 games
game_data = []
for game in games:
game_data.append({
'team1': game.get('home_team', 'Home'),
'team2': game.get('away_team', 'Away'),
'score1': game.get('home_score', 0),
'score2': game.get('away_score', 0),
'status': game.get('status', 'Live')
})
processed_data['games'] = game_data
return processed_data
except (json.JSONDecodeError, KeyError, TypeError) as e:
logger.error(f"Failed to process sports response: {e}")
return self.handle_error(endpoint, e)
class APIClient(ThreadedComponent):
"""REST API Client component"""
def __init__(self, message_bus: MessageBus, db_manager: DatabaseManager,
config_manager: ConfigManager, settings: APIClientConfig):
super().__init__("api_client", message_bus)
self.db_manager = db_manager
self.config_manager = config_manager
self.settings = settings
# HTTP session with retry logic
self.session = requests.Session()
self._setup_session()
# API endpoints configuration
self.endpoints: Dict[str, APIEndpoint] = {}
self._load_endpoints()
# Response handlers
self.response_handlers = {
'default': DefaultResponseHandler(),
'news': NewsResponseHandler(),
'sports': SportsResponseHandler()
}
# Statistics
self.stats = {
'total_requests': 0,
'successful_requests': 0,
'failed_requests': 0,
'start_time': datetime.utcnow()
}
# Register message queue
self.message_queue = self.message_bus.register_component(self.name)
logger.info("APIClient initialized")
def initialize(self) -> bool:
"""Initialize API client"""
try:
# Subscribe to messages
self.message_bus.subscribe(self.name, MessageType.CONFIG_UPDATE, self._handle_config_update)
self.message_bus.subscribe(self.name, MessageType.API_REQUEST, self._handle_api_request)
logger.info("APIClient initialized successfully")
return True
except Exception as e:
logger.error(f"APIClient initialization failed: {e}")
return False
def _setup_session(self):
"""Setup HTTP session with retry logic"""
retry_strategy = Retry(
total=self.settings.max_retries,
backoff_factor=self.settings.retry_backoff,
status_forcelist=[429, 500, 502, 503, 504],
)
adapter = HTTPAdapter(max_retries=retry_strategy)
self.session.mount("http://", adapter)
self.session.mount("https://", adapter)
# Set default headers
self.session.headers.update({
'User-Agent': f'MbetterClient/{self.settings.user_agent}',
'Accept': 'application/json, text/plain, */*',
'Accept-Encoding': 'gzip, deflate'
})
# Set timeout
self.session.timeout = self.settings.default_timeout
def _load_endpoints(self):
"""Load API endpoints from configuration"""
try:
endpoints_config = self.config_manager.get_section_config("api_endpoints")
if not endpoints_config:
# Create default configuration
default_endpoints = self._get_default_endpoints()
self.config_manager.update_section("api_endpoints", default_endpoints)
endpoints_config = default_endpoints
# Load endpoints
for name, config in endpoints_config.items():
self.endpoints[name] = APIEndpoint(name, config)
logger.info(f"Loaded {len(self.endpoints)} API endpoints")
except Exception as e:
logger.error(f"Failed to load API endpoints: {e}")
def _get_default_endpoints(self) -> Dict[str, Dict[str, Any]]:
"""Get default API endpoints configuration"""
return {
"news_example": {
"url": "https://newsapi.org/v2/top-headlines",
"method": "GET",
"headers": {
"X-API-Key": "your-api-key-here"
},
"params": {
"country": "us",
"pageSize": 5
},
"interval": 300, # 5 minutes
"enabled": False, # Disabled by default until API key is configured
"timeout": 30,
"retry_attempts": 3,
"retry_delay": 5,
"response_handler": "news"
},
"sports_example": {
"url": "https://api.sportsdata.io/v3/nfl/scores/json/LiveBoxScores",
"method": "GET",
"headers": {
"Ocp-Apim-Subscription-Key": "your-api-key-here"
},
"interval": 60, # 1 minute during games
"enabled": False,
"timeout": 30,
"retry_attempts": 3,
"retry_delay": 5,
"response_handler": "sports"
},
"weather_example": {
"url": "https://api.openweathermap.org/data/2.5/weather",
"method": "GET",
"params": {
"q": "New York",
"appid": "your-api-key-here",
"units": "metric"
},
"interval": 1800, # 30 minutes
"enabled": False,
"timeout": 15,
"retry_attempts": 2,
"retry_delay": 3,
"response_handler": "default"
}
}
def run(self):
"""Main run loop"""
try:
logger.info("APIClient thread started")
# Send ready status
ready_message = MessageBuilder.system_status(
sender=self.name,
status="ready",
details={
"endpoints": len(self.endpoints),
"enabled_endpoints": sum(1 for ep in self.endpoints.values() if ep.enabled)
}
)
self.message_bus.publish(ready_message)
# Main execution loop
while self.running:
try:
# Process messages
message = self.message_bus.get_message(self.name, timeout=1.0)
if message:
self._process_message(message)
# Execute scheduled API requests
self._execute_scheduled_requests()
# Update heartbeat
self.heartbeat()
time.sleep(1.0)
except Exception as e:
logger.error(f"APIClient run loop error: {e}")
time.sleep(5.0)
except Exception as e:
logger.error(f"APIClient run failed: {e}")
finally:
logger.info("APIClient thread ended")
def _execute_scheduled_requests(self):
"""Execute API requests that are due"""
for endpoint in self.endpoints.values():
if endpoint.should_execute():
self._execute_endpoint_request(endpoint)
def _execute_endpoint_request(self, endpoint: APIEndpoint):
"""Execute a single API request"""
try:
endpoint.last_request = datetime.utcnow()
endpoint.total_requests += 1
self.stats['total_requests'] += 1
logger.debug(f"Executing API request: {endpoint.name} -> {endpoint.url}")
# Prepare request parameters
request_kwargs = {
'method': endpoint.method,
'url': endpoint.url,
'headers': endpoint.headers,
'timeout': endpoint.timeout
}
if endpoint.method == 'GET':
request_kwargs['params'] = endpoint.params
else:
request_kwargs['json'] = endpoint.data
# Add authentication if configured
if endpoint.auth:
if endpoint.auth.get('type') == 'bearer':
request_kwargs['headers']['Authorization'] = f"Bearer {endpoint.auth.get('token')}"
elif endpoint.auth.get('type') == 'basic':
request_kwargs['auth'] = (endpoint.auth.get('username'), endpoint.auth.get('password'))
# Execute request
response = self.session.request(**request_kwargs)
response.raise_for_status()
# Handle successful response
handler = self.response_handlers.get(endpoint.response_handler, self.response_handlers['default'])
processed_data = handler.handle_response(endpoint, response)
# Update endpoint status
endpoint.last_success = datetime.utcnow()
endpoint.last_error = None
endpoint.consecutive_failures = 0
endpoint.successful_requests += 1
self.stats['successful_requests'] += 1
# Send response data via message bus
if processed_data:
response_message = Message(
type=MessageType.API_RESPONSE,
sender=self.name,
data={
'endpoint': endpoint.name,
'success': True,
'data': processed_data,
'timestamp': datetime.utcnow().isoformat()
}
)
self.message_bus.publish(response_message)
logger.debug(f"API request successful: {endpoint.name}")
except Exception as e:
# Handle request failure
endpoint.last_error = str(e)
endpoint.consecutive_failures += 1
self.stats['failed_requests'] += 1
logger.error(f"API request failed: {endpoint.name} - {e}")
# Send error message
error_message = Message(
type=MessageType.API_RESPONSE,
sender=self.name,
data={
'endpoint': endpoint.name,
'success': False,
'error': str(e),
'consecutive_failures': endpoint.consecutive_failures,
'timestamp': datetime.utcnow().isoformat()
}
)
self.message_bus.publish(error_message)
# Disable endpoint if too many consecutive failures
if endpoint.consecutive_failures >= self.settings.max_consecutive_failures:
endpoint.enabled = False
logger.warning(f"Endpoint disabled due to consecutive failures: {endpoint.name}")
def _process_message(self, message: Message):
"""Process received message"""
try:
# Messages are handled by subscribed handlers
pass
except Exception as e:
logger.error(f"Failed to process message: {e}")
def _handle_config_update(self, message: Message):
"""Handle configuration update message"""
try:
config_section = message.data.get("config_section")
if config_section == "api_endpoints":
logger.info("API endpoints configuration updated")
self._load_endpoints()
except Exception as e:
logger.error(f"Failed to handle config update: {e}")
def _handle_api_request(self, message: Message):
"""Handle manual API request message"""
try:
endpoint_name = message.data.get("endpoint")
if endpoint_name in self.endpoints:
endpoint = self.endpoints[endpoint_name]
self._execute_endpoint_request(endpoint)
else:
logger.warning(f"Unknown endpoint requested: {endpoint_name}")
except Exception as e:
logger.error(f"Failed to handle API request: {e}")
def get_endpoint_status(self, endpoint_name: str) -> Optional[Dict[str, Any]]:
"""Get status of a specific endpoint"""
endpoint = self.endpoints.get(endpoint_name)
if endpoint:
return endpoint.to_dict()
return None
def get_all_endpoints_status(self) -> Dict[str, Dict[str, Any]]:
"""Get status of all endpoints"""
return {name: endpoint.to_dict() for name, endpoint in self.endpoints.items()}
def get_client_stats(self) -> Dict[str, Any]:
"""Get client statistics"""
uptime = datetime.utcnow() - self.stats['start_time']
return {
'total_requests': self.stats['total_requests'],
'successful_requests': self.stats['successful_requests'],
'failed_requests': self.stats['failed_requests'],
'success_rate': (self.stats['successful_requests'] / max(1, self.stats['total_requests'])) * 100,
'uptime_seconds': uptime.total_seconds(),
'endpoints_count': len(self.endpoints),
'enabled_endpoints': sum(1 for ep in self.endpoints.values() if ep.enabled)
}
def update_endpoint(self, endpoint_name: str, config: Dict[str, Any]) -> bool:
"""Update endpoint configuration"""
try:
if endpoint_name in self.endpoints:
# Update existing endpoint
endpoint = self.endpoints[endpoint_name]
for key, value in config.items():
if hasattr(endpoint, key):
setattr(endpoint, key, value)
else:
# Create new endpoint
self.endpoints[endpoint_name] = APIEndpoint(endpoint_name, config)
# Save to configuration
all_endpoints = {name: ep.to_dict() for name, ep in self.endpoints.items()}
self.config_manager.update_section("api_endpoints", all_endpoints)
logger.info(f"Updated endpoint configuration: {endpoint_name}")
return True
except Exception as e:
logger.error(f"Failed to update endpoint: {e}")
return False
def shutdown(self):
"""Shutdown API client"""
try:
logger.info("Shutting down APIClient...")
if self.session:
self.session.close()
except Exception as e:
logger.error(f"APIClient shutdown error: {e}")
\ No newline at end of file
"""
Core application components for MbetterClient
"""
from .application import MbetterClientApplication
from .message_bus import MessageBus, Message, MessageType
from .thread_manager import ThreadManager
__all__ = [
'MbetterClientApplication',
'MessageBus',
'Message',
'MessageType',
'ThreadManager'
]
\ No newline at end of file
"""
Main application class that orchestrates all components and threads
"""
import sys
import time
import logging
import threading
from typing import Optional, Dict, Any
from pathlib import Path
from ..config.settings import AppSettings
from ..config.manager import ConfigManager
from ..database.manager import DatabaseManager
from .message_bus import MessageBus, Message, MessageType, MessageBuilder
from .thread_manager import ThreadManager
logger = logging.getLogger(__name__)
class MbetterClientApplication:
"""Main application class that coordinates all components"""
def __init__(self, settings: AppSettings):
self.settings = settings
self.running = False
self.shutdown_event = threading.Event()
# Core components
self.db_manager: Optional[DatabaseManager] = None
self.config_manager: Optional[ConfigManager] = None
self.message_bus: Optional[MessageBus] = None
self.thread_manager: Optional[ThreadManager] = None
# Component references
self.qt_player = None
self.web_dashboard = None
self.api_client = None
# Main loop thread
self._main_loop_thread: Optional[threading.Thread] = None
logger.info("MbetterClient application initialized")
def initialize(self) -> bool:
"""Initialize all application components"""
try:
logger.info("Initializing MbetterClient application...")
# Initialize database manager
if not self._initialize_database():
return False
# Initialize configuration manager
if not self._initialize_config():
return False
# Initialize message bus
if not self._initialize_message_bus():
return False
# Initialize thread manager
if not self._initialize_thread_manager():
return False
# Initialize components based on settings
if not self._initialize_components():
return False
logger.info("MbetterClient application initialized successfully")
return True
except Exception as e:
logger.error(f"Failed to initialize application: {e}")
return False
def _initialize_database(self) -> bool:
"""Initialize database manager"""
try:
db_path = self.settings.database.get_absolute_path()
self.db_manager = DatabaseManager(str(db_path))
if not self.db_manager.initialize():
logger.error("Database manager initialization failed")
return False
logger.info("Database manager initialized")
return True
except Exception as e:
logger.error(f"Database initialization failed: {e}")
return False
def _initialize_config(self) -> bool:
"""Initialize configuration manager"""
try:
self.config_manager = ConfigManager(self.db_manager)
if not self.config_manager.initialize():
logger.error("Configuration manager initialization failed")
return False
# Update settings from database
stored_settings = self.config_manager.get_settings()
if stored_settings:
# Merge runtime settings with stored settings
stored_settings.fullscreen = self.settings.fullscreen
stored_settings.web_host = self.settings.web_host
stored_settings.web_port = self.settings.web_port
stored_settings.database_path = self.settings.database_path
self.settings = stored_settings
logger.info("Configuration manager initialized")
return True
except Exception as e:
logger.error(f"Configuration initialization failed: {e}")
return False
def _initialize_message_bus(self) -> bool:
"""Initialize message bus"""
try:
self.message_bus = MessageBus(max_queue_size=1000)
# Register core component
self.message_bus.register_component("core")
# Subscribe to system messages
self.message_bus.subscribe("core", MessageType.SYSTEM_SHUTDOWN, self._handle_shutdown_message)
self.message_bus.subscribe("core", MessageType.CONFIG_UPDATE, self._handle_config_update)
self.message_bus.subscribe("core", MessageType.LOG_ENTRY, self._handle_log_entry)
logger.info("Message bus initialized")
return True
except Exception as e:
logger.error(f"Message bus initialization failed: {e}")
return False
def _initialize_thread_manager(self) -> bool:
"""Initialize thread manager"""
try:
self.thread_manager = ThreadManager(
message_bus=self.message_bus,
settings=self.settings
)
logger.info("Thread manager initialized")
return True
except Exception as e:
logger.error(f"Thread manager initialization failed: {e}")
return False
def _initialize_components(self) -> bool:
"""Initialize application components based on settings"""
try:
components_initialized = 0
# Initialize PyQt video player
if self.settings.enable_qt:
if self._initialize_qt_player():
components_initialized += 1
else:
logger.error("Qt player initialization failed")
return False
# Initialize web dashboard
if self.settings.enable_web:
if self._initialize_web_dashboard():
components_initialized += 1
else:
logger.error("Web dashboard initialization failed")
return False
# Initialize API client
if self.settings.enable_api_client:
if self._initialize_api_client():
components_initialized += 1
else:
logger.error("API client initialization failed")
return False
if components_initialized == 0:
logger.error("No components were initialized")
return False
logger.info(f"{components_initialized} components initialized")
return True
except Exception as e:
logger.error(f"Component initialization failed: {e}")
return False
def _initialize_qt_player(self) -> bool:
"""Initialize PyQt video player"""
try:
from ..qt_player.player import QtVideoPlayer
self.qt_player = QtVideoPlayer(
message_bus=self.message_bus,
settings=self.settings.qt
)
# Register with thread manager
self.thread_manager.register_component("qt_player", self.qt_player)
logger.info("Qt player initialized")
return True
except Exception as e:
logger.error(f"Qt player initialization failed: {e}")
return False
def _initialize_web_dashboard(self) -> bool:
"""Initialize web dashboard"""
try:
from ..web_dashboard.app import WebDashboard
self.web_dashboard = WebDashboard(
message_bus=self.message_bus,
db_manager=self.db_manager,
config_manager=self.config_manager,
settings=self.settings.web
)
# Register with thread manager
self.thread_manager.register_component("web_dashboard", self.web_dashboard)
logger.info("Web dashboard initialized")
return True
except Exception as e:
logger.error(f"Web dashboard initialization failed: {e}")
return False
def _initialize_api_client(self) -> bool:
"""Initialize API client"""
try:
from ..api_client.client import APIClient
self.api_client = APIClient(
message_bus=self.message_bus,
db_manager=self.db_manager,
config_manager=self.config_manager,
settings=self.settings.api
)
# Register with thread manager
self.thread_manager.register_component("api_client", self.api_client)
logger.info("API client initialized")
return True
except Exception as e:
logger.error(f"API client initialization failed: {e}")
return False
def run(self) -> int:
"""Run the application"""
try:
logger.info("Starting MbetterClient application...")
self.running = True
# Start all components
if not self.thread_manager.start_all():
logger.error("Failed to start components")
return 1
# Start main loop in separate thread
self._main_loop_thread = threading.Thread(
target=self._main_loop,
name="MainLoop",
daemon=False
)
self._main_loop_thread.start()
# Send system ready message
ready_message = MessageBuilder.system_status(
sender="core",
status="ready",
details={
"components": self.thread_manager.get_component_names(),
"version": self.settings.version
}
)
self.message_bus.publish(ready_message, broadcast=True)
logger.info("MbetterClient application started successfully")
# Wait for shutdown
self.shutdown_event.wait()
logger.info("Application shutdown initiated")
return self._cleanup()
except KeyboardInterrupt:
logger.info("Application interrupted by user")
return self._cleanup()
except Exception as e:
logger.error(f"Application run failed: {e}")
return self._cleanup()
def _main_loop(self):
"""Main application loop for message processing and coordination"""
logger.info("Main loop started")
last_stats_time = time.time()
stats_interval = 60 # Log stats every minute
try:
while self.running and not self.shutdown_event.is_set():
try:
# Process messages
message = self.message_bus.get_message("core", timeout=1.0)
if message:
self._process_core_message(message)
# Periodic tasks
current_time = time.time()
# Log statistics
if current_time - last_stats_time >= stats_interval:
self._log_statistics()
last_stats_time = current_time
# Placeholder for additional tasks
self._run_additional_tasks()
# Check component health
self._check_component_health()
except Exception as e:
logger.error(f"Main loop error: {e}")
time.sleep(1) # Prevent tight error loop
except Exception as e:
logger.error(f"Main loop fatal error: {e}")
logger.info("Main loop ended")
def _process_core_message(self, message: Message):
"""Process messages received by the core component"""
try:
logger.debug(f"Core processing message: {message}")
if message.type == MessageType.SYSTEM_STATUS:
self._handle_system_status(message)
elif message.type == MessageType.SYSTEM_ERROR:
self._handle_system_error(message)
elif message.type == MessageType.CONFIG_REQUEST:
self._handle_config_request(message)
else:
logger.debug(f"Unhandled message type in core: {message.type}")
except Exception as e:
logger.error(f"Failed to process core message: {e}")
def _handle_system_status(self, message: Message):
"""Handle system status messages"""
try:
status = message.data.get("status", "unknown")
details = message.data.get("details", {})
logger.info(f"System status from {message.sender}: {status}")
if status == "error":
logger.warning(f"Component error: {details}")
elif status == "ready":
logger.info(f"Component ready: {message.sender}")
except Exception as e:
logger.error(f"Failed to handle system status: {e}")
def _handle_system_error(self, message: Message):
"""Handle system error messages"""
try:
error_msg = message.data.get("error", "Unknown error")
details = message.data.get("details", {})
logger.error(f"System error from {message.sender}: {error_msg}")
logger.error(f"Error details: {details}")
# TODO: Implement error recovery strategies
except Exception as e:
logger.error(f"Failed to handle system error: {e}")
def _handle_config_request(self, message: Message):
"""Handle configuration requests"""
try:
requested_section = message.data.get("section", "all")
if requested_section == "all":
config_data = self.config_manager.get_web_config_dict()
else:
config_data = getattr(self.settings, requested_section, {})
if hasattr(config_data, "__dict__"):
config_data = config_data.__dict__
# Send response
response = MessageBuilder.config_update(
sender="core",
config_section=requested_section,
config_data=config_data
)
response.recipient = message.sender
response.correlation_id = message.correlation_id
self.message_bus.publish(response)
except Exception as e:
logger.error(f"Failed to handle config request: {e}")
def _handle_shutdown_message(self, message: Message):
"""Handle shutdown message"""
logger.info(f"Shutdown message received from {message.sender}")
self.shutdown()
def _handle_config_update(self, message: Message):
"""Handle configuration update message"""
try:
config_section = message.data.get("config_section")
config_data = message.data.get("config_data")
logger.info(f"Configuration update for section: {config_section}")
# Update configuration
if self.config_manager.update_from_web(config_data):
logger.info("Configuration updated successfully")
# Broadcast update to all components
update_message = MessageBuilder.config_update(
sender="core",
config_section=config_section,
config_data=config_data
)
self.message_bus.publish(update_message, broadcast=True)
else:
logger.error("Configuration update failed")
except Exception as e:
logger.error(f"Failed to handle config update: {e}")
def _handle_log_entry(self, message: Message):
"""Handle log entry message"""
try:
level = message.data.get("level", "INFO")
log_message = message.data.get("message", "")
details = message.data.get("details", {})
# Add to database log
self.db_manager.add_log_entry(
level=level,
component=message.sender,
message=log_message,
details=details
)
except Exception as e:
logger.error(f"Failed to handle log entry: {e}")
def _run_additional_tasks(self):
"""Placeholder for additional periodic tasks"""
# TODO: Implement additional tasks as requested by user
# This is where future extensions can be added
pass
def _check_component_health(self):
"""Check health of all components"""
try:
if self.thread_manager:
unhealthy_components = self.thread_manager.get_unhealthy_components()
if unhealthy_components:
logger.warning(f"Unhealthy components detected: {unhealthy_components}")
# TODO: Implement component restart logic
except Exception as e:
logger.error(f"Component health check failed: {e}")
def _log_statistics(self):
"""Log application statistics"""
try:
if self.message_bus and self.thread_manager:
msg_stats = self.message_bus.get_statistics()
thread_stats = self.thread_manager.get_statistics()
logger.info(f"Message bus stats: {msg_stats['total_components']} components, "
f"{msg_stats['total_messages_in_history']} messages")
logger.info(f"Thread manager stats: {thread_stats['total_threads']} threads, "
f"{thread_stats['running_threads']} running")
except Exception as e:
logger.error(f"Failed to log statistics: {e}")
def shutdown(self):
"""Shutdown the application"""
logger.info("Application shutdown requested")
self.running = False
self.shutdown_event.set()
# Send shutdown message to all components
if self.message_bus:
shutdown_message = Message(
type=MessageType.SYSTEM_SHUTDOWN,
sender="core",
data={"reason": "Application shutdown"}
)
self.message_bus.publish(shutdown_message, broadcast=True)
def _cleanup(self) -> int:
"""Cleanup application resources"""
logger.info("Cleaning up application resources...")
try:
# Stop thread manager
if self.thread_manager:
self.thread_manager.stop_all()
self.thread_manager.wait_for_shutdown(timeout=10.0)
# Wait for main loop thread
if self._main_loop_thread and self._main_loop_thread.is_alive():
self._main_loop_thread.join(timeout=5.0)
# Shutdown message bus
if self.message_bus:
self.message_bus.shutdown()
# Close database
if self.db_manager:
self.db_manager.close()
logger.info("Application cleanup completed")
return 0
except Exception as e:
logger.error(f"Cleanup failed: {e}")
return 1
def get_status(self) -> Dict[str, Any]:
"""Get current application status"""
try:
status = {
"running": self.running,
"version": self.settings.version,
"components": {},
"message_bus": {},
"threads": {}
}
if self.thread_manager:
status["threads"] = self.thread_manager.get_statistics()
status["components"] = {
name: "running" if self.thread_manager.is_component_running(name) else "stopped"
for name in self.thread_manager.get_component_names()
}
if self.message_bus:
status["message_bus"] = self.message_bus.get_statistics()
return status
except Exception as e:
logger.error(f"Failed to get status: {e}")
return {"error": str(e)}
\ No newline at end of file
"""
Message bus system for inter-thread communication using Queues
"""
import time
import json
import logging
import threading
from enum import Enum
from typing import Dict, Any, Optional, Callable, List
from queue import Queue, Empty
from dataclasses import dataclass, asdict
from datetime import datetime
logger = logging.getLogger(__name__)
class MessageType(Enum):
"""Message types for different operations"""
# Video control messages
VIDEO_PLAY = "VIDEO_PLAY"
VIDEO_PAUSE = "VIDEO_PAUSE"
VIDEO_STOP = "VIDEO_STOP"
VIDEO_SEEK = "VIDEO_SEEK"
VIDEO_VOLUME = "VIDEO_VOLUME"
VIDEO_FULLSCREEN = "VIDEO_FULLSCREEN"
VIDEO_PROGRESS = "VIDEO_PROGRESS"
VIDEO_STATUS = "VIDEO_STATUS"
VIDEO_ERROR = "VIDEO_ERROR"
# Template and overlay messages
TEMPLATE_CHANGE = "TEMPLATE_CHANGE"
OVERLAY_UPDATE = "OVERLAY_UPDATE"
OVERLAY_DATA = "OVERLAY_DATA"
# API client messages
API_REQUEST = "API_REQUEST"
API_RESPONSE = "API_RESPONSE"
API_ERROR = "API_ERROR"
API_CONFIG_CHANGE = "API_CONFIG_CHANGE"
# Configuration messages
CONFIG_UPDATE = "CONFIG_UPDATE"
CONFIG_REQUEST = "CONFIG_REQUEST"
CONFIG_RESPONSE = "CONFIG_RESPONSE"
# System messages
SYSTEM_STATUS = "SYSTEM_STATUS"
SYSTEM_ERROR = "SYSTEM_ERROR"
SYSTEM_SHUTDOWN = "SYSTEM_SHUTDOWN"
SYSTEM_READY = "SYSTEM_READY"
STATUS_REQUEST = "STATUS_REQUEST"
# Web dashboard messages
WEB_USER_LOGIN = "WEB_USER_LOGIN"
WEB_USER_LOGOUT = "WEB_USER_LOGOUT"
WEB_ACTION = "WEB_ACTION"
WEB_STATUS = "WEB_STATUS"
# Log messages
LOG_ENTRY = "LOG_ENTRY"
# Custom messages (for future extensions)
CUSTOM = "CUSTOM"
@dataclass
class Message:
"""Message structure for inter-thread communication"""
type: MessageType
data: Dict[str, Any]
sender: str
recipient: Optional[str] = None
timestamp: float = None
correlation_id: Optional[str] = None
priority: int = 0 # 0 = normal, 1 = high, 2 = critical
def __post_init__(self):
if self.timestamp is None:
self.timestamp = time.time()
def to_dict(self) -> Dict[str, Any]:
"""Convert message to dictionary"""
result = asdict(self)
result['type'] = self.type.value
return result
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Message':
"""Create message from dictionary"""
if isinstance(data['type'], str):
data['type'] = MessageType(data['type'])
return cls(**data)
def to_json(self) -> str:
"""Convert message to JSON string"""
return json.dumps(self.to_dict(), default=str)
@classmethod
def from_json(cls, json_str: str) -> 'Message':
"""Create message from JSON string"""
data = json.loads(json_str)
return cls.from_dict(data)
def is_response_to(self, original_message: 'Message') -> bool:
"""Check if this message is a response to another message"""
return (self.correlation_id is not None and
self.correlation_id == original_message.correlation_id)
def create_response(self, response_type: MessageType, response_data: Dict[str, Any],
sender: str) -> 'Message':
"""Create a response message to this message"""
return Message(
type=response_type,
data=response_data,
sender=sender,
recipient=self.sender,
correlation_id=self.correlation_id,
priority=self.priority
)
def __str__(self):
return f"Message({self.type.value}, {self.sender}->{self.recipient or 'ALL'})"
class MessageBus:
"""Central message bus for inter-thread communication"""
def __init__(self, max_queue_size: int = 1000):
self.max_queue_size = max_queue_size
self._queues: Dict[str, Queue] = {}
self._handlers: Dict[str, Dict[MessageType, List[Callable]]] = {}
self._global_handlers: Dict[MessageType, List[Callable]] = {}
self._running = False
self._lock = threading.RLock()
self._message_history: List[Message] = []
self._max_history = 1000
logger.info("MessageBus initialized")
def register_component(self, component_name: str) -> Queue:
"""Register a component and get its message queue"""
with self._lock:
if component_name not in self._queues:
self._queues[component_name] = Queue(maxsize=self.max_queue_size)
self._handlers[component_name] = {}
logger.info(f"Component '{component_name}' registered with MessageBus")
return self._queues[component_name]
def unregister_component(self, component_name: str):
"""Unregister a component"""
with self._lock:
if component_name in self._queues:
# Clear the queue
try:
while True:
self._queues[component_name].get_nowait()
except Empty:
pass
del self._queues[component_name]
del self._handlers[component_name]
logger.info(f"Component '{component_name}' unregistered from MessageBus")
def subscribe(self, component_name: str, message_type: MessageType,
handler: Callable[[Message], None]):
"""Subscribe to specific message type for a component"""
with self._lock:
if component_name not in self._handlers:
self._handlers[component_name] = {}
if message_type not in self._handlers[component_name]:
self._handlers[component_name][message_type] = []
self._handlers[component_name][message_type].append(handler)
logger.debug(f"Component '{component_name}' subscribed to {message_type.value}")
def subscribe_global(self, message_type: MessageType, handler: Callable[[Message], None]):
"""Subscribe to message type globally (all components)"""
with self._lock:
if message_type not in self._global_handlers:
self._global_handlers[message_type] = []
self._global_handlers[message_type].append(handler)
logger.debug(f"Global handler subscribed to {message_type.value}")
def publish(self, message: Message, broadcast: bool = False) -> bool:
"""Publish message to the bus"""
try:
with self._lock:
# Add to message history
self._add_to_history(message)
# Log the message
logger.debug(f"Publishing message: {message}")
if broadcast or message.recipient is None:
# Broadcast to all components
success_count = 0
for component_name, queue in self._queues.items():
if component_name != message.sender: # Don't send to sender
if self._deliver_to_queue(queue, message):
success_count += 1
logger.debug(f"Broadcast message delivered to {success_count} components")
else:
# Send to specific recipient
if message.recipient in self._queues:
if self._deliver_to_queue(self._queues[message.recipient], message):
logger.debug(f"Message delivered to {message.recipient}")
return True
else:
logger.warning(f"Failed to deliver message to {message.recipient} (queue full)")
return False
else:
logger.warning(f"Recipient '{message.recipient}' not found")
return False
# Call global handlers
self._call_handlers(self._global_handlers, message)
# Call component-specific handlers
if message.recipient and message.recipient in self._handlers:
self._call_handlers(self._handlers[message.recipient], message)
return True
except Exception as e:
logger.error(f"Failed to publish message: {e}")
return False
def _deliver_to_queue(self, queue: Queue, message: Message) -> bool:
"""Deliver message to a specific queue"""
try:
# Priority handling - critical messages skip queue size limits
if message.priority >= 2:
queue.put(message, block=False)
else:
# Check queue size for normal messages
if queue.qsize() < self.max_queue_size:
queue.put(message, block=False)
else:
logger.warning(f"Queue full, dropping message: {message}")
return False
return True
except Exception as e:
logger.error(f"Failed to deliver message to queue: {e}")
return False
def _call_handlers(self, handlers_dict: Dict[MessageType, List[Callable]], message: Message):
"""Call message handlers"""
try:
if message.type in handlers_dict:
for handler in handlers_dict[message.type]:
try:
handler(message)
except Exception as e:
logger.error(f"Message handler failed: {e}")
except Exception as e:
logger.error(f"Failed to call handlers: {e}")
def _add_to_history(self, message: Message):
"""Add message to history for debugging"""
try:
self._message_history.append(message)
# Trim history if too long
if len(self._message_history) > self._max_history:
self._message_history = self._message_history[-self._max_history//2:]
except Exception as e:
logger.error(f"Failed to add message to history: {e}")
def get_message(self, component_name: str, timeout: Optional[float] = None) -> Optional[Message]:
"""Get message for a component"""
if component_name not in self._queues:
logger.error(f"Component '{component_name}' not registered")
return None
try:
if timeout is None:
return self._queues[component_name].get_nowait()
else:
return self._queues[component_name].get(timeout=timeout)
except Empty:
return None
except Exception as e:
logger.error(f"Failed to get message: {e}")
return None
def has_messages(self, component_name: str) -> bool:
"""Check if component has pending messages"""
if component_name not in self._queues:
return False
return not self._queues[component_name].empty()
def get_queue_size(self, component_name: str) -> int:
"""Get current queue size for a component"""
if component_name not in self._queues:
return 0
return self._queues[component_name].qsize()
def clear_queue(self, component_name: str) -> int:
"""Clear all messages from a component's queue"""
if component_name not in self._queues:
return 0
cleared_count = 0
try:
while True:
self._queues[component_name].get_nowait()
cleared_count += 1
except Empty:
pass
logger.info(f"Cleared {cleared_count} messages from {component_name} queue")
return cleared_count
def get_message_history(self, limit: int = 100) -> List[Message]:
"""Get recent message history"""
with self._lock:
if limit <= 0:
return self._message_history.copy()
else:
return self._message_history[-limit:].copy()
def get_statistics(self) -> Dict[str, Any]:
"""Get message bus statistics"""
with self._lock:
stats = {
'total_components': len(self._queues),
'total_messages_in_history': len(self._message_history),
'components': {},
'message_types': {}
}
# Component statistics
for component_name, queue in self._queues.items():
stats['components'][component_name] = {
'queue_size': queue.qsize(),
'max_queue_size': self.max_queue_size,
'handlers_count': len(self._handlers.get(component_name, {}))
}
# Message type statistics
for message in self._message_history:
msg_type = message.type.value
if msg_type not in stats['message_types']:
stats['message_types'][msg_type] = 0
stats['message_types'][msg_type] += 1
return stats
def shutdown(self):
"""Shutdown message bus"""
with self._lock:
self._running = False
# Clear all queues
for component_name in list(self._queues.keys()):
self.clear_queue(component_name)
self.unregister_component(component_name)
# Clear handlers
self._handlers.clear()
self._global_handlers.clear()
# Clear history
self._message_history.clear()
logger.info("MessageBus shutdown complete")
class MessageBuilder:
"""Helper class for building messages"""
@staticmethod
def video_play(sender: str, file_path: str, template: str = "news_template",
overlay_data: Optional[Dict[str, Any]] = None) -> Message:
"""Create VIDEO_PLAY message"""
return Message(
type=MessageType.VIDEO_PLAY,
sender=sender,
data={
"file_path": file_path,
"template": template,
"overlay_data": overlay_data or {}
}
)
@staticmethod
def video_progress(sender: str, position: float, duration: float,
percentage: float) -> Message:
"""Create VIDEO_PROGRESS message"""
return Message(
type=MessageType.VIDEO_PROGRESS,
sender=sender,
data={
"position": position,
"duration": duration,
"percentage": percentage
}
)
@staticmethod
def template_change(sender: str, template_name: str,
template_data: Dict[str, Any]) -> Message:
"""Create TEMPLATE_CHANGE message"""
return Message(
type=MessageType.TEMPLATE_CHANGE,
sender=sender,
data={
"template_name": template_name,
"template_data": template_data
}
)
@staticmethod
def api_request(sender: str, url: str, method: str = "GET",
headers: Optional[Dict[str, str]] = None,
data: Optional[Dict[str, Any]] = None) -> Message:
"""Create API_REQUEST message"""
return Message(
type=MessageType.API_REQUEST,
sender=sender,
data={
"url": url,
"method": method,
"headers": headers or {},
"data": data or {}
}
)
@staticmethod
def api_response(sender: str, status_code: int, response_data: Dict[str, Any],
correlation_id: Optional[str] = None) -> Message:
"""Create API_RESPONSE message"""
return Message(
type=MessageType.API_RESPONSE,
sender=sender,
correlation_id=correlation_id,
data={
"status_code": status_code,
"response_data": response_data
}
)
@staticmethod
def config_update(sender: str, config_section: str,
config_data: Dict[str, Any]) -> Message:
"""Create CONFIG_UPDATE message"""
return Message(
type=MessageType.CONFIG_UPDATE,
sender=sender,
data={
"config_section": config_section,
"config_data": config_data
}
)
@staticmethod
def system_status(sender: str, status: str, details: Dict[str, Any]) -> Message:
"""Create SYSTEM_STATUS message"""
return Message(
type=MessageType.SYSTEM_STATUS,
sender=sender,
data={
"status": status,
"details": details
}
)
@staticmethod
def log_entry(sender: str, level: str, message: str,
details: Optional[Dict[str, Any]] = None) -> Message:
"""Create LOG_ENTRY message"""
return Message(
type=MessageType.LOG_ENTRY,
sender=sender,
data={
"level": level,
"message": message,
"details": details or {}
}
)
\ No newline at end of file
"""
Thread manager for coordinating multiple application components
"""
import time
import logging
import threading
from typing import Dict, Any, Optional, List, Callable
from abc import ABC, abstractmethod
from ..config.settings import AppSettings
from .message_bus import MessageBus, Message, MessageType
logger = logging.getLogger(__name__)
class ThreadedComponent(ABC):
"""Base class for threaded components"""
def __init__(self, name: str, message_bus: MessageBus):
self.name = name
self.message_bus = message_bus
self.running = False
self.thread: Optional[threading.Thread] = None
self.shutdown_event = threading.Event()
self._last_heartbeat = time.time()
@abstractmethod
def initialize(self) -> bool:
"""Initialize component"""
pass
@abstractmethod
def run(self):
"""Main component run loop"""
pass
@abstractmethod
def shutdown(self):
"""Shutdown component"""
pass
def start(self) -> bool:
"""Start component in a separate thread"""
try:
if self.running:
logger.warning(f"Component {self.name} is already running")
return True
# Initialize component
if not self.initialize():
logger.error(f"Failed to initialize component {self.name}")
return False
# Start thread
self.thread = threading.Thread(
target=self._thread_wrapper,
name=f"{self.name}Thread",
daemon=False
)
self.running = True
self.shutdown_event.clear()
self.thread.start()
logger.info(f"Component {self.name} started successfully")
return True
except Exception as e:
logger.error(f"Failed to start component {self.name}: {e}")
return False
def stop(self, timeout: float = 5.0) -> bool:
"""Stop component"""
try:
if not self.running:
logger.warning(f"Component {self.name} is not running")
return True
logger.info(f"Stopping component {self.name}...")
# Signal shutdown
self.running = False
self.shutdown_event.set()
# Call component-specific shutdown
self.shutdown()
# Wait for thread to finish
if self.thread and self.thread.is_alive():
self.thread.join(timeout=timeout)
if self.thread.is_alive():
logger.warning(f"Component {self.name} thread did not stop within timeout")
return False
logger.info(f"Component {self.name} stopped successfully")
return True
except Exception as e:
logger.error(f"Failed to stop component {self.name}: {e}")
return False
def _thread_wrapper(self):
"""Thread wrapper with error handling"""
try:
logger.debug(f"Component {self.name} thread started")
self.run()
except Exception as e:
logger.error(f"Component {self.name} thread failed: {e}")
# Send error message
try:
error_message = Message(
type=MessageType.SYSTEM_ERROR,
sender=self.name,
data={
"error": str(e),
"component": self.name
}
)
self.message_bus.publish(error_message, broadcast=True)
except:
pass # Don't let error reporting crash the thread
finally:
self.running = False
logger.debug(f"Component {self.name} thread ended")
def is_alive(self) -> bool:
"""Check if component thread is alive"""
return self.thread is not None and self.thread.is_alive()
def is_healthy(self) -> bool:
"""Check if component is healthy (heartbeat within last 30 seconds)"""
return time.time() - self._last_heartbeat < 30.0
def heartbeat(self):
"""Update heartbeat timestamp"""
self._last_heartbeat = time.time()
def get_status(self) -> Dict[str, Any]:
"""Get component status"""
return {
"name": self.name,
"running": self.running,
"thread_alive": self.is_alive(),
"healthy": self.is_healthy(),
"last_heartbeat": self._last_heartbeat
}
class ThreadManager:
"""Manages multiple threaded components"""
def __init__(self, message_bus: MessageBus, settings: AppSettings):
self.message_bus = message_bus
self.settings = settings
self.components: Dict[str, ThreadedComponent] = {}
self._lock = threading.RLock()
logger.info("Thread manager initialized")
def register_component(self, name: str, component: ThreadedComponent):
"""Register a component with the thread manager"""
with self._lock:
if name in self.components:
logger.warning(f"Component {name} is already registered")
return
self.components[name] = component
logger.info(f"Component {name} registered with thread manager")
def unregister_component(self, name: str):
"""Unregister a component"""
with self._lock:
if name in self.components:
component = self.components[name]
# Stop component if running
if component.running:
component.stop()
del self.components[name]
logger.info(f"Component {name} unregistered from thread manager")
def start_component(self, name: str) -> bool:
"""Start a specific component"""
with self._lock:
if name not in self.components:
logger.error(f"Component {name} not found")
return False
return self.components[name].start()
def stop_component(self, name: str, timeout: float = 5.0) -> bool:
"""Stop a specific component"""
with self._lock:
if name not in self.components:
logger.error(f"Component {name} not found")
return False
return self.components[name].stop(timeout)
def start_all(self) -> bool:
"""Start all registered components"""
logger.info("Starting all components...")
success = True
started_components = []
with self._lock:
for name, component in self.components.items():
if component.start():
started_components.append(name)
logger.info(f"Component {name} started")
else:
logger.error(f"Failed to start component {name}")
success = False
break
if not success:
# Stop already started components
logger.error("Starting all components failed, stopping started components...")
for name in started_components:
self.stop_component(name)
return False
logger.info(f"All {len(started_components)} components started successfully")
return True
def stop_all(self, timeout: float = 10.0) -> bool:
"""Stop all components"""
logger.info("Stopping all components...")
success = True
stop_timeout = timeout / max(len(self.components), 1) # Distribute timeout
with self._lock:
for name, component in self.components.items():
if not component.stop(stop_timeout):
logger.error(f"Failed to stop component {name}")
success = False
else:
logger.info(f"Component {name} stopped")
if success:
logger.info("All components stopped successfully")
else:
logger.warning("Some components failed to stop cleanly")
return success
def restart_component(self, name: str, timeout: float = 5.0) -> bool:
"""Restart a specific component"""
logger.info(f"Restarting component {name}")
if self.stop_component(name, timeout):
time.sleep(1) # Brief pause
return self.start_component(name)
return False
def is_component_running(self, name: str) -> bool:
"""Check if a component is running"""
with self._lock:
if name not in self.components:
return False
return self.components[name].running and self.components[name].is_alive()
def is_component_healthy(self, name: str) -> bool:
"""Check if a component is healthy"""
with self._lock:
if name not in self.components:
return False
return self.components[name].is_healthy()
def get_component_names(self) -> List[str]:
"""Get list of registered component names"""
with self._lock:
return list(self.components.keys())
def get_running_components(self) -> List[str]:
"""Get list of currently running components"""
with self._lock:
return [name for name, comp in self.components.items()
if comp.running and comp.is_alive()]
def get_unhealthy_components(self) -> List[str]:
"""Get list of unhealthy components"""
with self._lock:
return [name for name, comp in self.components.items()
if comp.running and not comp.is_healthy()]
def get_component_status(self, name: str) -> Optional[Dict[str, Any]]:
"""Get status of a specific component"""
with self._lock:
if name not in self.components:
return None
return self.components[name].get_status()
def get_all_component_status(self) -> Dict[str, Dict[str, Any]]:
"""Get status of all components"""
with self._lock:
return {name: comp.get_status() for name, comp in self.components.items()}
def wait_for_shutdown(self, timeout: Optional[float] = None):
"""Wait for all component threads to finish"""
logger.info("Waiting for component threads to finish...")
with self._lock:
threads = [(name, comp.thread) for name, comp in self.components.items()
if comp.thread and comp.thread.is_alive()]
if not threads:
logger.info("No active threads to wait for")
return
# Calculate per-thread timeout
per_thread_timeout = timeout / len(threads) if timeout else None
for name, thread in threads:
try:
thread.join(timeout=per_thread_timeout)
if thread.is_alive():
logger.warning(f"Thread for component {name} did not finish within timeout")
else:
logger.debug(f"Thread for component {name} finished")
except Exception as e:
logger.error(f"Error waiting for thread {name}: {e}")
logger.info("Finished waiting for component threads")
def get_statistics(self) -> Dict[str, Any]:
"""Get thread manager statistics"""
with self._lock:
total_threads = len(self.components)
running_threads = len([c for c in self.components.values()
if c.running and c.is_alive()])
healthy_threads = len([c for c in self.components.values()
if c.is_healthy()])
return {
"total_threads": total_threads,
"running_threads": running_threads,
"healthy_threads": healthy_threads,
"unhealthy_threads": total_threads - healthy_threads,
"components": {
name: {
"running": comp.running,
"alive": comp.is_alive(),
"healthy": comp.is_healthy()
}
for name, comp in self.components.items()
}
}
def perform_health_check(self) -> Dict[str, Any]:
"""Perform comprehensive health check on all components"""
logger.info("Performing component health check...")
health_report = {
"timestamp": time.time(),
"overall_healthy": True,
"components": {},
"issues": []
}
with self._lock:
for name, component in self.components.items():
comp_status = component.get_status()
health_report["components"][name] = comp_status
# Check for issues
if component.running and not component.is_alive():
issue = f"Component {name} is marked as running but thread is dead"
health_report["issues"].append(issue)
health_report["overall_healthy"] = False
logger.warning(issue)
if component.running and not component.is_healthy():
issue = f"Component {name} is unhealthy (no recent heartbeat)"
health_report["issues"].append(issue)
health_report["overall_healthy"] = False
logger.warning(issue)
if health_report["overall_healthy"]:
logger.info("All components are healthy")
else:
logger.warning(f"Health check found {len(health_report['issues'])} issues")
return health_report
def cleanup(self):
"""Cleanup thread manager resources"""
logger.info("Cleaning up thread manager...")
# Stop all components
self.stop_all(timeout=5.0)
# Wait for threads to finish
self.wait_for_shutdown(timeout=10.0)
# Clear components
with self._lock:
self.components.clear()
logger.info("Thread manager cleanup completed")
\ No newline at end of file
"""
Database management for MbetterClient with SQLite and versioning support
"""
from .manager import DatabaseManager
from .models import (
BaseModel,
DatabaseVersion,
UserModel,
ConfigurationModel,
ApiTokenModel,
LogEntryModel,
TemplateModel
)
from .migrations import DatabaseMigration, run_migrations
__all__ = [
'DatabaseManager',
'BaseModel',
'DatabaseVersion',
'UserModel',
'ConfigurationModel',
'ApiTokenModel',
'LogEntryModel',
'TemplateModel',
'DatabaseMigration',
'run_migrations'
]
\ No newline at end of file
"""
Database manager with SQLite support and automatic migrations
"""
import os
import json
import sqlite3
import logging
from pathlib import Path
from typing import Dict, Any, Optional, List
from datetime import datetime, timedelta
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker, scoped_session
from sqlalchemy.exc import SQLAlchemyError
from .models import (
Base, DatabaseVersion, UserModel, ConfigurationModel,
ApiTokenModel, LogEntryModel, TemplateModel, SystemMetricModel, SessionModel
)
from .migrations import run_migrations
logger = logging.getLogger(__name__)
class DatabaseManager:
"""Manages SQLite database operations with versioning and migrations"""
def __init__(self, db_path: str):
self.db_path = Path(db_path)
self.engine = None
self.session_factory = None
self.Session = None
self._initialized = False
def initialize(self) -> bool:
"""Initialize database connection and run migrations"""
try:
# Ensure database directory exists
self.db_path.parent.mkdir(parents=True, exist_ok=True)
# Create database URL
db_url = f"sqlite:///{self.db_path}"
# Create engine with proper SQLite configuration
self.engine = create_engine(
db_url,
echo=False,
pool_pre_ping=True,
connect_args={
'check_same_thread': False,
'timeout': 30
}
)
# Configure SQLite for better performance and reliability
with self.engine.connect() as conn:
conn.execute(text("PRAGMA journal_mode=WAL"))
conn.execute(text("PRAGMA synchronous=NORMAL"))
conn.execute(text("PRAGMA cache_size=10000"))
conn.execute(text("PRAGMA temp_store=MEMORY"))
conn.execute(text("PRAGMA mmap_size=268435456")) # 256MB
conn.commit()
# Create session factory
self.session_factory = sessionmaker(bind=self.engine)
self.Session = scoped_session(self.session_factory)
# Create all tables
Base.metadata.create_all(self.engine)
# Run database migrations
if not run_migrations(self):
logger.error("Database migrations failed")
return False
# Create default admin user if none exists
self._create_default_admin()
# Initialize default templates
self._initialize_default_templates()
self._initialized = True
logger.info("Database manager initialized successfully")
return True
except Exception as e:
logger.error(f"Failed to initialize database manager: {e}")
return False
def get_session(self):
"""Get database session"""
if not self._initialized:
raise RuntimeError("Database manager not initialized")
return self.Session()
def close(self):
"""Close database connections"""
if self.Session:
self.Session.remove()
if self.engine:
self.engine.dispose()
logger.info("Database connections closed")
def backup_database(self, backup_path: Optional[str] = None) -> bool:
"""Create database backup"""
try:
if backup_path is None:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = f"{self.db_path}.backup_{timestamp}"
# Use SQLite backup API for consistent backup
with sqlite3.connect(str(self.db_path)) as source:
with sqlite3.connect(backup_path) as backup:
source.backup(backup)
logger.info(f"Database backed up to {backup_path}")
return True
except Exception as e:
logger.error(f"Database backup failed: {e}")
return False
def restore_database(self, backup_path: str) -> bool:
"""Restore database from backup"""
try:
if not os.path.exists(backup_path):
logger.error(f"Backup file not found: {backup_path}")
return False
# Close existing connections
if self.Session:
self.Session.remove()
if self.engine:
self.engine.dispose()
# Replace current database with backup
import shutil
shutil.copy2(backup_path, self.db_path)
# Reinitialize
return self.initialize()
except Exception as e:
logger.error(f"Database restore failed: {e}")
return False
def vacuum_database(self) -> bool:
"""Vacuum database to optimize storage"""
try:
with self.engine.connect() as conn:
conn.execute(text("VACUUM"))
conn.commit()
logger.info("Database vacuum completed")
return True
except Exception as e:
logger.error(f"Database vacuum failed: {e}")
return False
# Configuration methods
def get_configuration(self) -> Optional[Dict[str, Any]]:
"""Get application configuration from database"""
try:
session = self.get_session()
config_entry = session.query(ConfigurationModel).filter_by(
key='app_settings'
).first()
if config_entry:
return config_entry.get_typed_value()
return None
except Exception as e:
logger.error(f"Failed to get configuration: {e}")
return None
finally:
session.close()
def save_configuration(self, config_data: Dict[str, Any]) -> bool:
"""Save application configuration to database"""
try:
session = self.get_session()
config_entry = session.query(ConfigurationModel).filter_by(
key='app_settings'
).first()
if config_entry:
config_entry.set_typed_value(config_data)
config_entry.updated_at = datetime.utcnow()
else:
config_entry = ConfigurationModel(
key='app_settings',
description='Main application settings'
)
config_entry.set_typed_value(config_data)
session.add(config_entry)
session.commit()
logger.debug("Configuration saved successfully")
return True
except Exception as e:
logger.error(f"Failed to save configuration: {e}")
session.rollback()
return False
finally:
session.close()
def get_config_value(self, key: str, default: Any = None) -> Any:
"""Get specific configuration value"""
try:
session = self.get_session()
config_entry = session.query(ConfigurationModel).filter_by(key=key).first()
if config_entry:
return config_entry.get_typed_value()
return default
except Exception as e:
logger.error(f"Failed to get config value '{key}': {e}")
return default
finally:
session.close()
def set_config_value(self, key: str, value: Any) -> bool:
"""Set specific configuration value"""
try:
session = self.get_session()
config_entry = session.query(ConfigurationModel).filter_by(key=key).first()
if config_entry:
config_entry.set_typed_value(value)
config_entry.updated_at = datetime.utcnow()
else:
config_entry = ConfigurationModel(key=key)
config_entry.set_typed_value(value)
session.add(config_entry)
session.commit()
return True
except Exception as e:
logger.error(f"Failed to set config value '{key}': {e}")
session.rollback()
return False
finally:
session.close()
def delete_config_value(self, key: str) -> bool:
"""Delete configuration value"""
try:
session = self.get_session()
config_entry = session.query(ConfigurationModel).filter_by(key=key).first()
if config_entry:
session.delete(config_entry)
session.commit()
return True
return False
except Exception as e:
logger.error(f"Failed to delete config value '{key}': {e}")
session.rollback()
return False
finally:
session.close()
def get_all_config_values(self) -> Dict[str, Any]:
"""Get all configuration values"""
try:
session = self.get_session()
config_entries = session.query(ConfigurationModel).all()
result = {}
for entry in config_entries:
if entry.key != 'app_settings': # Skip main settings
result[entry.key] = entry.get_typed_value()
return result
except Exception as e:
logger.error(f"Failed to get all config values: {e}")
return {}
finally:
session.close()
# User management methods
def create_user(self, username: str, email: str, password: str, is_admin: bool = False) -> Optional[UserModel]:
"""Create new user"""
try:
session = self.get_session()
# Check if user exists
existing_user = session.query(UserModel).filter(
(UserModel.username == username) | (UserModel.email == email)
).first()
if existing_user:
logger.warning(f"User already exists: {username} or {email}")
return None
# Create new user
user = UserModel(
username=username,
email=email,
is_admin=is_admin
)
user.set_password(password)
session.add(user)
session.commit()
logger.info(f"User created: {username}")
return user
except Exception as e:
logger.error(f"Failed to create user: {e}")
session.rollback()
return None
finally:
session.close()
def get_user_by_username(self, username: str) -> Optional[UserModel]:
"""Get user by username"""
try:
session = self.get_session()
return session.query(UserModel).filter_by(username=username).first()
except Exception as e:
logger.error(f"Failed to get user by username: {e}")
return None
finally:
session.close()
def get_user_by_id(self, user_id: int) -> Optional[UserModel]:
"""Get user by ID"""
try:
session = self.get_session()
return session.query(UserModel).get(user_id)
except Exception as e:
logger.error(f"Failed to get user by ID: {e}")
return None
finally:
session.close()
# Logging methods
def add_log_entry(self, level: str, component: str, message: str,
details: Optional[Dict[str, Any]] = None, user_id: Optional[int] = None,
session_id: Optional[str] = None, ip_address: Optional[str] = None) -> bool:
"""Add log entry to database"""
try:
session = self.get_session()
log_entry = LogEntryModel(
level=level,
component=component,
message=message,
details=details,
user_id=user_id,
session_id=session_id,
ip_address=ip_address
)
session.add(log_entry)
session.commit()
return True
except Exception as e:
logger.error(f"Failed to add log entry: {e}")
session.rollback()
return False
finally:
session.close()
def get_log_entries(self, level: Optional[str] = None, component: Optional[str] = None,
limit: int = 100, offset: int = 0) -> List[LogEntryModel]:
"""Get log entries with filtering"""
try:
session = self.get_session()
query = session.query(LogEntryModel)
if level:
query = query.filter_by(level=level)
if component:
query = query.filter_by(component=component)
query = query.order_by(LogEntryModel.created_at.desc())
query = query.limit(limit).offset(offset)
return query.all()
except Exception as e:
logger.error(f"Failed to get log entries: {e}")
return []
finally:
session.close()
def cleanup_old_logs(self, days: int = 30) -> int:
"""Clean up old log entries"""
try:
session = self.get_session()
cutoff_date = datetime.utcnow() - timedelta(days=days)
deleted_count = session.query(LogEntryModel).filter(
LogEntryModel.created_at < cutoff_date
).delete()
session.commit()
if deleted_count > 0:
logger.info(f"Cleaned up {deleted_count} old log entries")
return deleted_count
except Exception as e:
logger.error(f"Failed to cleanup old logs: {e}")
session.rollback()
return 0
finally:
session.close()
def _create_default_admin(self):
"""Create default admin user if none exists"""
try:
session = self.get_session()
admin_user = session.query(UserModel).filter_by(is_admin=True).first()
if not admin_user:
# Create default admin
admin = UserModel(
username='admin',
email='admin@mbetterclient.local',
is_admin=True
)
admin.set_password('admin123')
session.add(admin)
session.commit()
logger.info("Default admin user created (admin/admin123)")
except Exception as e:
logger.error(f"Failed to create default admin: {e}")
session.rollback()
finally:
session.close()
def _initialize_default_templates(self):
"""Initialize default video overlay templates"""
try:
session = self.get_session()
# Check if default template exists
existing_template = session.query(TemplateModel).filter_by(
name='news_template', is_system=True
).first()
if not existing_template:
# Create default news template
news_template = TemplateModel(
name='news_template',
display_name='News Template',
description='Default news-style overlay template',
is_system=True,
category='news',
author='MbetterClient',
template_data={
'layout': {
'width': 1920,
'height': 1080
},
'elements': [
{
'id': 'news_bar',
'type': 'rectangle',
'x': 0,
'y': 950,
'width': 1920,
'height': 130,
'color': '#1a1a1a',
'opacity': 0.9
},
{
'id': 'athlete_image',
'type': 'image',
'x': 50,
'y': 960,
'width': 110,
'height': 110,
'source': 'assets/boxing_athletes.png',
'fit': 'cover'
},
{
'id': 'scrolling_text',
'type': 'text',
'x': 180,
'y': 990,
'width': 1500,
'height': 50,
'text': 'Breaking News: Boxing Match Updates',
'font_family': 'Arial',
'font_size': 32,
'font_weight': 'bold',
'color': '#ffffff',
'animation': {
'type': 'scroll',
'direction': 'left',
'speed': 100
}
}
]
}
)
session.add(news_template)
session.commit()
logger.info("Default news template created")
except Exception as e:
logger.error(f"Failed to initialize default templates: {e}")
session.rollback()
finally:
session.close()
\ No newline at end of file
"""
Database migration system for MbetterClient
"""
import logging
from typing import List, Dict, Any, Optional
from datetime import datetime
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from .models import DatabaseVersion
logger = logging.getLogger(__name__)
class DatabaseMigration:
"""Base class for database migrations"""
def __init__(self, version: str, description: str):
self.version = version
self.description = description
def up(self, db_manager) -> bool:
"""Apply migration"""
raise NotImplementedError("Migration must implement up() method")
def down(self, db_manager) -> bool:
"""Rollback migration"""
raise NotImplementedError("Migration must implement down() method")
def __str__(self):
return f"Migration {self.version}: {self.description}"
class Migration_001_InitialSchema(DatabaseMigration):
"""Initial database schema"""
def __init__(self):
super().__init__("001", "Initial database schema")
def up(self, db_manager) -> bool:
"""Create initial schema - handled by SQLAlchemy create_all"""
try:
# Schema is created by SQLAlchemy Base.metadata.create_all()
logger.info("Initial schema created by SQLAlchemy")
return True
except Exception as e:
logger.error(f"Failed to create initial schema: {e}")
return False
def down(self, db_manager) -> bool:
"""Drop all tables"""
try:
with db_manager.engine.connect() as conn:
# Get all table names
result = conn.execute(text("""
SELECT name FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
"""))
tables = [row[0] for row in result.fetchall()]
# Drop all tables
for table in tables:
conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
conn.commit()
logger.info("All tables dropped")
return True
except Exception as e:
logger.error(f"Failed to drop tables: {e}")
return False
class Migration_002_AddIndexes(DatabaseMigration):
"""Add performance indexes"""
def __init__(self):
super().__init__("002", "Add performance indexes")
def up(self, db_manager) -> bool:
"""Add indexes for better query performance"""
try:
with db_manager.engine.connect() as conn:
# Additional indexes for better performance
indexes = [
"CREATE INDEX IF NOT EXISTS ix_log_entries_user_component ON log_entries(user_id, component)",
"CREATE INDEX IF NOT EXISTS ix_api_tokens_user_active ON api_tokens(user_id, is_active)",
"CREATE INDEX IF NOT EXISTS ix_sessions_user_active ON sessions(user_id, is_active)",
"CREATE INDEX IF NOT EXISTS ix_system_metrics_component_name ON system_metrics(component, metric_name)",
]
for index_sql in indexes:
conn.execute(text(index_sql))
conn.commit()
logger.info("Performance indexes added")
return True
except Exception as e:
logger.error(f"Failed to add indexes: {e}")
return False
def down(self, db_manager) -> bool:
"""Remove added indexes"""
try:
with db_manager.engine.connect() as conn:
indexes = [
"DROP INDEX IF EXISTS ix_log_entries_user_component",
"DROP INDEX IF EXISTS ix_api_tokens_user_active",
"DROP INDEX IF EXISTS ix_sessions_user_active",
"DROP INDEX IF EXISTS ix_system_metrics_component_name",
]
for index_sql in indexes:
conn.execute(text(index_sql))
conn.commit()
logger.info("Performance indexes removed")
return True
except Exception as e:
logger.error(f"Failed to remove indexes: {e}")
return False
class Migration_003_AddTemplateVersioning(DatabaseMigration):
"""Add template versioning support"""
def __init__(self):
super().__init__("003", "Add template versioning support")
def up(self, db_manager) -> bool:
"""Add versioning columns to templates"""
try:
with db_manager.engine.connect() as conn:
# Check if columns already exist
result = conn.execute(text("PRAGMA table_info(templates)"))
columns = [row[1] for row in result.fetchall()]
if 'parent_template_id' not in columns:
conn.execute(text("""
ALTER TABLE templates
ADD COLUMN parent_template_id INTEGER
REFERENCES templates(id)
"""))
if 'version_major' not in columns:
conn.execute(text("ALTER TABLE templates ADD COLUMN version_major INTEGER DEFAULT 1"))
if 'version_minor' not in columns:
conn.execute(text("ALTER TABLE templates ADD COLUMN version_minor INTEGER DEFAULT 0"))
if 'version_patch' not in columns:
conn.execute(text("ALTER TABLE templates ADD COLUMN version_patch INTEGER DEFAULT 0"))
conn.commit()
logger.info("Template versioning columns added")
return True
except Exception as e:
logger.error(f"Failed to add template versioning: {e}")
return False
def down(self, db_manager) -> bool:
"""Remove versioning columns - SQLite doesn't support DROP COLUMN easily"""
logger.warning("SQLite doesn't support DROP COLUMN - versioning columns will remain")
return True
class Migration_004_AddUserPreferences(DatabaseMigration):
"""Add user preferences storage"""
def __init__(self):
super().__init__("004", "Add user preferences storage")
def up(self, db_manager) -> bool:
"""Create user preferences table"""
try:
with db_manager.engine.connect() as conn:
conn.execute(text("""
CREATE TABLE IF NOT EXISTS user_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
preference_key VARCHAR(100) NOT NULL,
preference_value TEXT NOT NULL,
value_type VARCHAR(20) DEFAULT 'string',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, preference_key)
)
"""))
# Add indexes
conn.execute(text("""
CREATE INDEX IF NOT EXISTS ix_user_preferences_user_key
ON user_preferences(user_id, preference_key)
"""))
conn.commit()
logger.info("User preferences table created")
return True
except Exception as e:
logger.error(f"Failed to create user preferences table: {e}")
return False
def down(self, db_manager) -> bool:
"""Drop user preferences table"""
try:
with db_manager.engine.connect() as conn:
conn.execute(text("DROP TABLE IF EXISTS user_preferences"))
conn.commit()
logger.info("User preferences table dropped")
return True
except Exception as e:
logger.error(f"Failed to drop user preferences table: {e}")
return False
# Registry of all migrations in order
MIGRATIONS: List[DatabaseMigration] = [
Migration_001_InitialSchema(),
Migration_002_AddIndexes(),
Migration_003_AddTemplateVersioning(),
Migration_004_AddUserPreferences(),
]
def get_applied_migrations(db_manager) -> List[str]:
"""Get list of applied migration versions"""
try:
session = db_manager.get_session()
versions = session.query(DatabaseVersion.version).all()
return [v[0] for v in versions]
except Exception as e:
logger.error(f"Failed to get applied migrations: {e}")
return []
finally:
session.close()
def mark_migration_applied(db_manager, migration: DatabaseMigration) -> bool:
"""Mark migration as applied"""
try:
session = db_manager.get_session()
# Check if already applied
existing = session.query(DatabaseVersion).filter_by(version=migration.version).first()
if not existing:
db_version = DatabaseVersion(
version=migration.version,
description=migration.description,
applied_at=datetime.utcnow()
)
session.add(db_version)
session.commit()
logger.info(f"Migration {migration.version} marked as applied")
return True
except Exception as e:
logger.error(f"Failed to mark migration as applied: {e}")
session.rollback()
return False
finally:
session.close()
def unmark_migration_applied(db_manager, version: str) -> bool:
"""Remove migration from applied list"""
try:
session = db_manager.get_session()
migration = session.query(DatabaseVersion).filter_by(version=version).first()
if migration:
session.delete(migration)
session.commit()
logger.info(f"Migration {version} unmarked as applied")
return True
except Exception as e:
logger.error(f"Failed to unmark migration: {e}")
session.rollback()
return False
finally:
session.close()
def run_migrations(db_manager) -> bool:
"""Run all pending migrations"""
try:
applied_versions = get_applied_migrations(db_manager)
logger.info(f"Applied migrations: {applied_versions}")
success = True
applied_count = 0
for migration in MIGRATIONS:
if migration.version not in applied_versions:
logger.info(f"Applying migration: {migration}")
if migration.up(db_manager):
if mark_migration_applied(db_manager, migration):
applied_count += 1
logger.info(f"Migration {migration.version} applied successfully")
else:
logger.error(f"Failed to mark migration {migration.version} as applied")
success = False
break
else:
logger.error(f"Migration {migration.version} failed")
success = False
break
else:
logger.debug(f"Migration {migration.version} already applied")
if success:
if applied_count > 0:
logger.info(f"Successfully applied {applied_count} migrations")
else:
logger.info("All migrations up to date")
else:
logger.error("Migration process failed")
return success
except Exception as e:
logger.error(f"Migration process failed: {e}")
return False
def rollback_migration(db_manager, version: str) -> bool:
"""Rollback a specific migration"""
try:
# Find the migration
migration = None
for m in MIGRATIONS:
if m.version == version:
migration = m
break
if not migration:
logger.error(f"Migration {version} not found")
return False
# Check if it's applied
applied_versions = get_applied_migrations(db_manager)
if version not in applied_versions:
logger.error(f"Migration {version} is not applied")
return False
logger.info(f"Rolling back migration: {migration}")
if migration.down(db_manager):
if unmark_migration_applied(db_manager, version):
logger.info(f"Migration {version} rolled back successfully")
return True
else:
logger.error(f"Failed to unmark migration {version}")
return False
else:
logger.error(f"Migration {version} rollback failed")
return False
except Exception as e:
logger.error(f"Migration rollback failed: {e}")
return False
def get_migration_status(db_manager) -> Dict[str, Any]:
"""Get current migration status"""
try:
applied_versions = get_applied_migrations(db_manager)
status = {
'total_migrations': len(MIGRATIONS),
'applied_migrations': len(applied_versions),
'pending_migrations': len(MIGRATIONS) - len(applied_versions),
'applied_versions': applied_versions,
'pending_versions': [],
'migrations': []
}
for migration in MIGRATIONS:
is_applied = migration.version in applied_versions
migration_info = {
'version': migration.version,
'description': migration.description,
'applied': is_applied,
'applied_at': None
}
if is_applied:
# Get applied date
session = db_manager.get_session()
try:
db_version = session.query(DatabaseVersion).filter_by(
version=migration.version
).first()
if db_version:
migration_info['applied_at'] = db_version.applied_at.isoformat()
finally:
session.close()
else:
status['pending_versions'].append(migration.version)
status['migrations'].append(migration_info)
return status
except Exception as e:
logger.error(f"Failed to get migration status: {e}")
return {
'error': str(e),
'total_migrations': 0,
'applied_migrations': 0,
'pending_migrations': 0
}
\ No newline at end of file
"""
SQLAlchemy database models for MbetterClient
"""
import json
import hashlib
from datetime import datetime, timedelta
from typing import Dict, Any, Optional, List
from sqlalchemy import (
Column, Integer, String, Text, DateTime, Boolean, Float,
JSON, ForeignKey, UniqueConstraint, Index, create_engine
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship, sessionmaker
from werkzeug.security import generate_password_hash, check_password_hash
Base = declarative_base()
class BaseModel(Base):
"""Base model with common fields"""
__abstract__ = True
id = Column(Integer, primary_key=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
def to_dict(self, exclude_fields: Optional[List[str]] = None) -> Dict[str, Any]:
"""Convert model to dictionary"""
exclude_fields = exclude_fields or []
result = {}
for column in self.__table__.columns:
if column.name not in exclude_fields:
value = getattr(self, column.name)
if isinstance(value, datetime):
result[column.name] = value.isoformat()
else:
result[column.name] = value
return result
def update_from_dict(self, data: Dict[str, Any], exclude_fields: Optional[List[str]] = None):
"""Update model from dictionary"""
exclude_fields = exclude_fields or ['id', 'created_at']
for key, value in data.items():
if hasattr(self, key) and key not in exclude_fields:
setattr(self, key, value)
self.updated_at = datetime.utcnow()
class DatabaseVersion(BaseModel):
"""Database schema version tracking"""
__tablename__ = 'database_versions'
version = Column(String(50), nullable=False, unique=True)
description = Column(String(255))
applied_at = Column(DateTime, default=datetime.utcnow, nullable=False)
def __repr__(self):
return f'<DatabaseVersion {self.version}>'
class UserModel(BaseModel):
"""User authentication and management"""
__tablename__ = 'users'
__table_args__ = (
Index('ix_users_username', 'username'),
Index('ix_users_email', 'email'),
)
username = Column(String(80), unique=True, nullable=False)
email = Column(String(120), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
is_admin = Column(Boolean, default=False, nullable=False)
last_login = Column(DateTime)
login_attempts = Column(Integer, default=0, nullable=False)
locked_until = Column(DateTime)
# Relationships
api_tokens = relationship('ApiTokenModel', back_populates='user', cascade='all, delete-orphan')
log_entries = relationship('LogEntryModel', back_populates='user')
def set_password(self, password: str):
"""Set password hash"""
self.password_hash = generate_password_hash(password)
def check_password(self, password: str) -> bool:
"""Check password against hash"""
return check_password_hash(self.password_hash, password)
def is_locked(self) -> bool:
"""Check if account is locked"""
if self.locked_until is None:
return False
return datetime.utcnow() < self.locked_until
def lock_account(self, minutes: int = 15):
"""Lock account for specified minutes"""
self.locked_until = datetime.utcnow() + timedelta(minutes=minutes)
self.login_attempts = 0
def unlock_account(self):
"""Unlock account"""
self.locked_until = None
self.login_attempts = 0
def increment_login_attempts(self):
"""Increment failed login attempts"""
self.login_attempts += 1
def reset_login_attempts(self):
"""Reset login attempts on successful login"""
self.login_attempts = 0
self.last_login = datetime.utcnow()
def to_dict(self, exclude_fields: Optional[List[str]] = None) -> Dict[str, Any]:
"""Convert to dictionary, excluding sensitive data"""
if exclude_fields is None:
exclude_fields = ['password_hash']
else:
exclude_fields.append('password_hash')
return super().to_dict(exclude_fields)
def __repr__(self):
return f'<User {self.username}>'
class ConfigurationModel(BaseModel):
"""Application configuration storage"""
__tablename__ = 'configuration'
__table_args__ = (
Index('ix_configuration_key', 'key'),
)
key = Column(String(255), unique=True, nullable=False)
value = Column(Text, nullable=False)
value_type = Column(String(50), default='string', nullable=False) # string, json, int, float, bool
description = Column(String(500))
is_system = Column(Boolean, default=False, nullable=False) # System vs user configurable
def get_typed_value(self) -> Any:
"""Get value converted to proper type"""
if self.value_type == 'json':
try:
return json.loads(self.value)
except json.JSONDecodeError:
return None
elif self.value_type == 'int':
try:
return int(self.value)
except (ValueError, TypeError):
return 0
elif self.value_type == 'float':
try:
return float(self.value)
except (ValueError, TypeError):
return 0.0
elif self.value_type == 'bool':
return self.value.lower() in ('true', '1', 'yes', 'on')
else:
return self.value
def set_typed_value(self, value: Any):
"""Set value with automatic type detection"""
if isinstance(value, dict) or isinstance(value, list):
self.value = json.dumps(value)
self.value_type = 'json'
elif isinstance(value, bool):
self.value = str(value).lower()
self.value_type = 'bool'
elif isinstance(value, int):
self.value = str(value)
self.value_type = 'int'
elif isinstance(value, float):
self.value = str(value)
self.value_type = 'float'
else:
self.value = str(value)
self.value_type = 'string'
def __repr__(self):
return f'<Configuration {self.key}={self.value}>'
class ApiTokenModel(BaseModel):
"""API tokens for dashboard authentication"""
__tablename__ = 'api_tokens'
__table_args__ = (
Index('ix_api_tokens_token_hash', 'token_hash'),
Index('ix_api_tokens_user_id', 'user_id'),
)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
name = Column(String(255), nullable=False)
token_hash = Column(String(255), nullable=False, unique=True)
is_active = Column(Boolean, default=True, nullable=False)
expires_at = Column(DateTime, nullable=False)
last_used_at = Column(DateTime)
last_used_ip = Column(String(45))
permissions = Column(JSON, default=list) # List of permission strings
# Relationships
user = relationship('UserModel', back_populates='api_tokens')
@staticmethod
def hash_token(token: str) -> str:
"""Hash token for secure storage"""
return hashlib.sha256(token.encode('utf-8')).hexdigest()
@staticmethod
def verify_token(token: str, token_hash: str) -> bool:
"""Verify token against hash"""
return ApiTokenModel.hash_token(token) == token_hash
def is_expired(self) -> bool:
"""Check if token is expired"""
return datetime.utcnow() > self.expires_at
def is_valid(self) -> bool:
"""Check if token is valid"""
return self.is_active and not self.is_expired()
def update_last_used(self, ip_address: Optional[str] = None):
"""Update last used timestamp and IP"""
self.last_used_at = datetime.utcnow()
if ip_address:
self.last_used_ip = ip_address
def revoke(self):
"""Revoke token"""
self.is_active = False
def extend_expiration(self, days: int = 365):
"""Extend token expiration"""
self.expires_at = datetime.utcnow() + timedelta(days=days)
def has_permission(self, permission: str) -> bool:
"""Check if token has specific permission"""
if not self.permissions:
return False
return permission in self.permissions
def to_dict(self, exclude_fields: Optional[List[str]] = None) -> Dict[str, Any]:
"""Convert to dictionary, excluding token hash"""
if exclude_fields is None:
exclude_fields = ['token_hash']
else:
exclude_fields.append('token_hash')
result = super().to_dict(exclude_fields)
result['is_expired'] = self.is_expired()
result['is_valid'] = self.is_valid()
return result
def __repr__(self):
return f'<ApiToken {self.name} for User {self.user_id}>'
class LogEntryModel(BaseModel):
"""System logging"""
__tablename__ = 'log_entries'
__table_args__ = (
Index('ix_log_entries_level', 'level'),
Index('ix_log_entries_component', 'component'),
Index('ix_log_entries_created_at', 'created_at'),
)
level = Column(String(20), nullable=False) # DEBUG, INFO, WARNING, ERROR, CRITICAL
component = Column(String(100), nullable=False) # qt_player, web_dashboard, api_client, core
message = Column(Text, nullable=False)
details = Column(JSON) # Additional structured data
# Optional associations
user_id = Column(Integer, ForeignKey('users.id'))
session_id = Column(String(255))
ip_address = Column(String(45))
# Relationships
user = relationship('UserModel', back_populates='log_entries')
def __repr__(self):
return f'<LogEntry {self.level}: {self.message[:50]}...>'
class TemplateModel(BaseModel):
"""Video overlay templates"""
__tablename__ = 'templates'
__table_args__ = (
Index('ix_templates_name', 'name'),
)
name = Column(String(100), unique=True, nullable=False)
display_name = Column(String(200), nullable=False)
description = Column(Text)
template_data = Column(JSON, nullable=False) # Template configuration and layout
preview_image = Column(String(500)) # Path to preview image
is_active = Column(Boolean, default=True, nullable=False)
is_system = Column(Boolean, default=False, nullable=False) # System vs user created
# Template metadata
author = Column(String(100))
version = Column(String(20), default='1.0.0')
category = Column(String(50), default='custom') # news, sports, general, custom
def get_template_config(self) -> Dict[str, Any]:
"""Get template configuration"""
if isinstance(self.template_data, dict):
return self.template_data
elif isinstance(self.template_data, str):
try:
return json.loads(self.template_data)
except json.JSONDecodeError:
return {}
else:
return {}
def set_template_config(self, config: Dict[str, Any]):
"""Set template configuration"""
self.template_data = config
def validate_template(self) -> bool:
"""Validate template data structure"""
try:
config = self.get_template_config()
# Required fields
required_fields = ['layout', 'elements']
for field in required_fields:
if field not in config:
return False
# Validate elements
if not isinstance(config['elements'], list):
return False
for element in config['elements']:
if not isinstance(element, dict):
return False
if 'type' not in element or 'id' not in element:
return False
return True
except Exception:
return False
def __repr__(self):
return f'<Template {self.name}>'
class SystemMetricModel(BaseModel):
"""System performance and usage metrics"""
__tablename__ = 'system_metrics'
__table_args__ = (
Index('ix_system_metrics_metric_name', 'metric_name'),
Index('ix_system_metrics_created_at', 'created_at'),
)
metric_name = Column(String(100), nullable=False)
metric_value = Column(Float, nullable=False)
metric_unit = Column(String(20)) # bytes, percentage, count, seconds, etc.
component = Column(String(50)) # qt_player, web_dashboard, api_client, system
details = Column(JSON) # Additional metric data
def __repr__(self):
return f'<SystemMetric {self.metric_name}: {self.metric_value} {self.metric_unit}>'
class SessionModel(BaseModel):
"""Web dashboard user sessions"""
__tablename__ = 'sessions'
__table_args__ = (
Index('ix_sessions_session_id', 'session_id'),
Index('ix_sessions_user_id', 'user_id'),
)
session_id = Column(String(255), unique=True, nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
ip_address = Column(String(45), nullable=False)
user_agent = Column(Text)
is_active = Column(Boolean, default=True, nullable=False)
expires_at = Column(DateTime, nullable=False)
last_activity = Column(DateTime, default=datetime.utcnow, nullable=False)
# Relationships
user = relationship('UserModel')
def is_expired(self) -> bool:
"""Check if session is expired"""
return datetime.utcnow() > self.expires_at
def is_valid(self) -> bool:
"""Check if session is valid"""
return self.is_active and not self.is_expired()
def extend_session(self, hours: int = 8):
"""Extend session expiration"""
self.expires_at = datetime.utcnow() + timedelta(hours=hours)
self.last_activity = datetime.utcnow()
def deactivate(self):
"""Deactivate session"""
self.is_active = False
def __repr__(self):
return f'<Session {self.session_id} for User {self.user_id}>'
\ No newline at end of file
"""
PyQt video player with overlay templates for MbetterClient
"""
from .player import QtVideoPlayer
from .overlay_engine import OverlayEngine, OverlayRenderer
from .templates import TemplateManager, NewsTemplate
__all__ = [
'QtVideoPlayer',
'OverlayEngine',
'OverlayRenderer',
'TemplateManager',
'NewsTemplate'
]
\ No newline at end of file
"""
Overlay engine for rendering dynamic overlays on video content
"""
import time
import logging
from typing import Dict, Any, Optional, List, Tuple
from pathlib import Path
from PyQt5.QtCore import Qt, QRect, QPoint, QTimer, QPropertyAnimation, QEasingCurve
from PyQt5.QtGui import (
QPainter, QPen, QBrush, QColor, QFont, QPixmap, QFontMetrics,
QLinearGradient, QRadialGradient, QPolygon
)
from PyQt5.QtWidgets import QGraphicsEffect
logger = logging.getLogger(__name__)
class OverlayElement:
"""Base class for overlay elements"""
def __init__(self, element_id: str, element_type: str, config: Dict[str, Any]):
self.id = element_id
self.type = element_type
self.config = config
self.visible = config.get('visible', True)
self.opacity = config.get('opacity', 1.0)
self.x = config.get('x', 0)
self.y = config.get('y', 0)
self.width = config.get('width', 100)
self.height = config.get('height', 50)
self.z_index = config.get('z_index', 0)
# Animation properties
self.animation_config = config.get('animation', {})
self.animation_start_time = None
def get_rect(self) -> QRect:
"""Get element rectangle"""
return QRect(int(self.x), int(self.y), int(self.width), int(self.height))
def update_animation(self, elapsed_time: float):
"""Update animation state"""
if not self.animation_config:
return
animation_type = self.animation_config.get('type')
if animation_type == 'scroll':
self._update_scroll_animation(elapsed_time)
elif animation_type == 'fade':
self._update_fade_animation(elapsed_time)
elif animation_type == 'bounce':
self._update_bounce_animation(elapsed_time)
def _update_scroll_animation(self, elapsed_time: float):
"""Update scrolling text animation"""
speed = self.animation_config.get('speed', 100) # pixels per second
direction = self.animation_config.get('direction', 'left')
if direction == 'left':
self.x -= speed * elapsed_time / 1000.0
elif direction == 'right':
self.x += speed * elapsed_time / 1000.0
elif direction == 'up':
self.y -= speed * elapsed_time / 1000.0
elif direction == 'down':
self.y += speed * elapsed_time / 1000.0
def _update_fade_animation(self, elapsed_time: float):
"""Update fade animation"""
duration = self.animation_config.get('duration', 2000) # ms
fade_type = self.animation_config.get('fade_type', 'in')
if self.animation_start_time is None:
self.animation_start_time = elapsed_time
progress = (elapsed_time - self.animation_start_time) / duration
progress = max(0.0, min(1.0, progress))
if fade_type == 'in':
self.opacity = progress
elif fade_type == 'out':
self.opacity = 1.0 - progress
elif fade_type == 'in_out':
if progress <= 0.5:
self.opacity = progress * 2
else:
self.opacity = (1.0 - progress) * 2
def _update_bounce_animation(self, elapsed_time: float):
"""Update bounce animation"""
import math
frequency = self.animation_config.get('frequency', 1.0) # Hz
amplitude = self.animation_config.get('amplitude', 10) # pixels
offset = amplitude * math.sin(2 * math.pi * frequency * elapsed_time / 1000.0)
self.y = self.config.get('y', 0) + offset
def render(self, painter: QPainter, canvas_rect: QRect):
"""Render element (to be implemented by subclasses)"""
pass
class RectangleElement(OverlayElement):
"""Rectangle overlay element"""
def __init__(self, element_id: str, config: Dict[str, Any]):
super().__init__(element_id, 'rectangle', config)
self.color = QColor(config.get('color', '#000000'))
self.border_color = QColor(config.get('border_color', '#ffffff'))
self.border_width = config.get('border_width', 0)
self.corner_radius = config.get('corner_radius', 0)
def render(self, painter: QPainter, canvas_rect: QRect):
"""Render rectangle"""
if not self.visible or self.opacity <= 0:
return
# Set opacity
painter.setOpacity(self.opacity)
rect = self.get_rect()
# Fill
if self.color.alpha() > 0:
brush = QBrush(self.color)
painter.setBrush(brush)
else:
painter.setBrush(Qt.NoBrush)
# Border
if self.border_width > 0:
pen = QPen(self.border_color, self.border_width)
painter.setPen(pen)
else:
painter.setPen(Qt.NoPen)
# Draw
if self.corner_radius > 0:
painter.drawRoundedRect(rect, self.corner_radius, self.corner_radius)
else:
painter.drawRect(rect)
class TextElement(OverlayElement):
"""Text overlay element"""
def __init__(self, element_id: str, config: Dict[str, Any]):
super().__init__(element_id, 'text', config)
self.text = config.get('text', '')
self.font_family = config.get('font_family', 'Arial')
self.font_size = config.get('font_size', 16)
self.font_weight = config.get('font_weight', 'normal')
self.color = QColor(config.get('color', '#ffffff'))
self.background_color = QColor(config.get('background_color', 'transparent'))
self.alignment = config.get('alignment', 'left')
self.word_wrap = config.get('word_wrap', False)
# Scrolling text properties
self.scroll_position = 0
self.text_width = 0
def set_text(self, text: str):
"""Update text content"""
self.text = text
self.scroll_position = 0 # Reset scroll position
def render(self, painter: QPainter, canvas_rect: QRect):
"""Render text"""
if not self.visible or self.opacity <= 0 or not self.text:
return
# Set opacity
painter.setOpacity(self.opacity)
# Setup font
font = QFont(self.font_family, self.font_size)
if self.font_weight == 'bold':
font.setBold(True)
painter.setFont(font)
# Get text metrics
metrics = QFontMetrics(font)
self.text_width = metrics.boundingRect(self.text).width()
rect = self.get_rect()
# Background
if self.background_color.alpha() > 0:
painter.fillRect(rect, self.background_color)
# Text color
painter.setPen(self.color)
# Handle scrolling animation
if self.animation_config.get('type') == 'scroll':
self._render_scrolling_text(painter, rect, metrics)
else:
self._render_static_text(painter, rect)
def _render_scrolling_text(self, painter: QPainter, rect: QRect, metrics: QFontMetrics):
"""Render scrolling text"""
if self.text_width <= rect.width():
# Text fits, no scrolling needed
self._render_static_text(painter, rect)
return
# Calculate scroll position
direction = self.animation_config.get('direction', 'left')
if direction == 'left':
# Reset position when text fully scrolled out
if self.x < -self.text_width:
self.x = rect.right()
# Draw text at current position
text_rect = QRect(int(self.x), rect.y(), self.text_width, rect.height())
else:
text_rect = rect
# Draw text
alignment = Qt.AlignVCenter
if self.alignment == 'center':
alignment |= Qt.AlignHCenter
elif self.alignment == 'right':
alignment |= Qt.AlignRight
else:
alignment |= Qt.AlignLeft
painter.drawText(text_rect, alignment, self.text)
def _render_static_text(self, painter: QPainter, rect: QRect):
"""Render static text"""
# Text alignment
alignment = Qt.AlignVCenter
if self.alignment == 'center':
alignment |= Qt.AlignHCenter
elif self.alignment == 'right':
alignment |= Qt.AlignRight
else:
alignment |= Qt.AlignLeft
if self.word_wrap:
alignment |= Qt.TextWordWrap
painter.drawText(rect, alignment, self.text)
class ImageElement(OverlayElement):
"""Image overlay element"""
def __init__(self, element_id: str, config: Dict[str, Any]):
super().__init__(element_id, 'image', config)
self.source = config.get('source', '')
self.fit = config.get('fit', 'contain') # contain, cover, fill, scale-down
self.pixmap: Optional[QPixmap] = None
self._load_image()
def _load_image(self):
"""Load image from source"""
try:
if self.source:
# Handle both absolute and relative paths
if Path(self.source).is_absolute():
image_path = Path(self.source)
else:
# Relative to project root
project_root = Path(__file__).parent.parent.parent
image_path = project_root / self.source
if image_path.exists():
self.pixmap = QPixmap(str(image_path))
logger.debug(f"Loaded image: {image_path}")
else:
logger.warning(f"Image not found: {image_path}")
except Exception as e:
logger.error(f"Failed to load image {self.source}: {e}")
def set_source(self, source: str):
"""Update image source"""
self.source = source
self._load_image()
def render(self, painter: QPainter, canvas_rect: QRect):
"""Render image"""
if not self.visible or self.opacity <= 0 or not self.pixmap:
return
# Set opacity
painter.setOpacity(self.opacity)
rect = self.get_rect()
# Scale pixmap based on fit mode
scaled_pixmap = self._scale_pixmap(self.pixmap, rect)
# Calculate position for centering if needed
if self.fit in ['contain', 'scale-down']:
x_offset = (rect.width() - scaled_pixmap.width()) // 2
y_offset = (rect.height() - scaled_pixmap.height()) // 2
draw_pos = QPoint(rect.x() + x_offset, rect.y() + y_offset)
else:
draw_pos = rect.topLeft()
painter.drawPixmap(draw_pos, scaled_pixmap)
def _scale_pixmap(self, pixmap: QPixmap, target_rect: QRect) -> QPixmap:
"""Scale pixmap according to fit mode"""
if self.fit == 'fill':
return pixmap.scaled(target_rect.size(), Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
elif self.fit == 'cover':
return pixmap.scaled(target_rect.size(), Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
elif self.fit == 'contain':
return pixmap.scaled(target_rect.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)
elif self.fit == 'scale-down':
if pixmap.width() <= target_rect.width() and pixmap.height() <= target_rect.height():
return pixmap # No scaling needed
else:
return pixmap.scaled(target_rect.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)
else:
return pixmap
class OverlayRenderer:
"""Manages rendering of overlay elements"""
def __init__(self):
self.elements: Dict[str, OverlayElement] = {}
self.start_time = time.time() * 1000 # milliseconds
self.last_update_time = self.start_time
def add_element(self, element: OverlayElement):
"""Add overlay element"""
self.elements[element.id] = element
def remove_element(self, element_id: str):
"""Remove overlay element"""
if element_id in self.elements:
del self.elements[element_id]
def get_element(self, element_id: str) -> Optional[OverlayElement]:
"""Get overlay element by ID"""
return self.elements.get(element_id)
def clear_elements(self):
"""Clear all elements"""
self.elements.clear()
def update_element_data(self, element_id: str, data: Dict[str, Any]):
"""Update element data"""
element = self.get_element(element_id)
if element:
if element.type == 'text' and 'text' in data:
element.set_text(data['text'])
elif element.type == 'image' and 'source' in data:
element.set_source(data['source'])
# Update other properties
for key, value in data.items():
if hasattr(element, key):
setattr(element, key, value)
def render(self, painter: QPainter, canvas_rect: QRect):
"""Render all overlay elements"""
current_time = time.time() * 1000
elapsed_time = current_time - self.last_update_time
self.last_update_time = current_time
# Sort elements by z-index
sorted_elements = sorted(self.elements.values(), key=lambda e: e.z_index)
# Render elements
for element in sorted_elements:
try:
# Update animations
element.update_animation(current_time - self.start_time)
# Render element
element.render(painter, canvas_rect)
except Exception as e:
logger.error(f"Failed to render element {element.id}: {e}")
class OverlayEngine:
"""Main overlay engine"""
def __init__(self):
self.renderer = OverlayRenderer()
self.template_config: Optional[Dict[str, Any]] = None
self.overlay_data: Dict[str, Any] = {}
self.playback_position = 0
self.playback_duration = 0
def load_template(self, template_config: Dict[str, Any], overlay_data: Dict[str, Any] = None):
"""Load overlay template"""
try:
self.template_config = template_config
self.overlay_data = overlay_data or {}
# Clear existing elements
self.renderer.clear_elements()
# Create elements from template
elements_config = template_config.get('elements', [])
for element_config in elements_config:
element = self._create_element(element_config)
if element:
self.renderer.add_element(element)
# Apply overlay data
self._apply_overlay_data()
logger.info(f"Loaded template with {len(elements_config)} elements")
except Exception as e:
logger.error(f"Failed to load template: {e}")
def _create_element(self, config: Dict[str, Any]) -> Optional[OverlayElement]:
"""Create overlay element from configuration"""
element_type = config.get('type')
element_id = config.get('id')
if not element_type or not element_id:
logger.warning("Element missing type or id")
return None
try:
if element_type == 'rectangle':
return RectangleElement(element_id, config)
elif element_type == 'text':
return TextElement(element_id, config)
elif element_type == 'image':
return ImageElement(element_id, config)
else:
logger.warning(f"Unknown element type: {element_type}")
return None
except Exception as e:
logger.error(f"Failed to create element {element_id}: {e}")
return None
def update_overlay_data(self, overlay_data: Dict[str, Any]):
"""Update overlay data"""
self.overlay_data.update(overlay_data)
self._apply_overlay_data()
def _apply_overlay_data(self):
"""Apply overlay data to elements"""
try:
for element_id, data in self.overlay_data.items():
self.renderer.update_element_data(element_id, data)
except Exception as e:
logger.error(f"Failed to apply overlay data: {e}")
def update_playback_position(self, position: int, duration: int):
"""Update video playback position"""
self.playback_position = position
self.playback_duration = duration
def render(self, painter: QPainter, canvas_rect: QRect):
"""Render overlay"""
try:
self.renderer.render(painter, canvas_rect)
except Exception as e:
logger.error(f"Overlay render failed: {e}")
def get_element_by_id(self, element_id: str) -> Optional[OverlayElement]:
"""Get element by ID"""
return self.renderer.get_element(element_id)
def set_element_visibility(self, element_id: str, visible: bool):
"""Set element visibility"""
element = self.get_element_by_id(element_id)
if element:
element.visible = visible
def set_element_text(self, element_id: str, text: str):
"""Set text content for text element"""
element = self.get_element_by_id(element_id)
if element and element.type == 'text':
element.set_text(text)
def set_element_image(self, element_id: str, image_source: str):
"""Set image source for image element"""
element = self.get_element_by_id(element_id)
if element and element.type == 'image':
element.set_source(image_source)
\ No newline at end of file
"""
PyQt video player with overlay support
"""
import sys
import time
import logging
from pathlib import Path
from typing import Optional, Dict, Any
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QSlider, QFrame, QGraphicsView, QGraphicsScene,
QGraphicsVideoItem, QGraphicsProxyWidget
)
from PyQt5.QtCore import (
Qt, QTimer, QThread, pyqtSignal, QUrl, QRect, QPropertyAnimation,
QEasingCurve, QSequentialAnimationGroup
)
from PyQt5.QtGui import (
QFont, QPainter, QPen, QBrush, QColor, QPixmap, QMovie,
QLinearGradient, QFontMetrics
)
from PyQt5.QtMultimedia import QMediaPlayer, QMediaContent
from PyQt5.QtMultimediaWidgets import QVideoWidget
from ..core.thread_manager import ThreadedComponent
from ..core.message_bus import MessageBus, Message, MessageType, MessageBuilder
from ..config.settings import QtConfig
from .overlay_engine import OverlayEngine
from .templates import TemplateManager
logger = logging.getLogger(__name__)
class VideoWidget(QVideoWidget):
"""Custom video widget with overlay support"""
def __init__(self, parent=None):
super().__init__(parent)
self.overlay_engine = None
self.setStyleSheet("background-color: black;")
def set_overlay_engine(self, overlay_engine):
"""Set overlay engine for rendering"""
self.overlay_engine = overlay_engine
def paintEvent(self, event):
"""Custom paint event to render overlays"""
super().paintEvent(event)
if self.overlay_engine:
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
try:
self.overlay_engine.render(painter, self.rect())
except Exception as e:
logger.error(f"Overlay rendering error: {e}")
finally:
painter.end()
class PlayerControlsWidget(QWidget):
"""Video player controls"""
play_pause_clicked = pyqtSignal()
stop_clicked = pyqtSignal()
seek_requested = pyqtSignal(int)
def __init__(self, parent=None):
super().__init__(parent)
self.setup_ui()
def setup_ui(self):
"""Setup control UI"""
layout = QHBoxLayout(self)
layout.setContentsMargins(10, 5, 10, 5)
# Play/Pause button
self.play_pause_btn = QPushButton("⏸️")
self.play_pause_btn.setFixedSize(40, 30)
self.play_pause_btn.clicked.connect(self.play_pause_clicked.emit)
# Stop button
self.stop_btn = QPushButton("⏹️")
self.stop_btn.setFixedSize(40, 30)
self.stop_btn.clicked.connect(self.stop_clicked.emit)
# Position slider
self.position_slider = QSlider(Qt.Horizontal)
self.position_slider.setMinimum(0)
self.position_slider.setMaximum(100)
self.position_slider.sliderPressed.connect(self._on_slider_pressed)
self.position_slider.sliderReleased.connect(self._on_slider_released)
# Time labels
self.current_time_label = QLabel("00:00")
self.duration_label = QLabel("00:00")
# Layout
layout.addWidget(self.play_pause_btn)
layout.addWidget(self.stop_btn)
layout.addWidget(self.current_time_label)
layout.addWidget(self.position_slider, 1) # Stretch
layout.addWidget(self.duration_label)
# Style
self.setStyleSheet("""
QWidget {
background-color: rgba(0, 0, 0, 180);
color: white;
}
QPushButton {
border: 1px solid #555;
border-radius: 3px;
background-color: #333;
}
QPushButton:hover {
background-color: #555;
}
QSlider::groove:horizontal {
border: 1px solid #555;
height: 4px;
background: #222;
}
QSlider::handle:horizontal {
background: #fff;
border: 1px solid #555;
width: 12px;
margin: -4px 0;
border-radius: 6px;
}
""")
self.slider_pressed = False
def _on_slider_pressed(self):
self.slider_pressed = True
def _on_slider_released(self):
self.slider_pressed = False
self.seek_requested.emit(self.position_slider.value())
def update_position(self, position: int, duration: int):
"""Update position display"""
if not self.slider_pressed and duration > 0:
self.position_slider.setValue(int(position * 100 / duration))
self.current_time_label.setText(self._format_time(position))
self.duration_label.setText(self._format_time(duration))
def update_play_pause_button(self, is_playing: bool):
"""Update play/pause button state"""
self.play_pause_btn.setText("⏸️" if is_playing else "▶️")
def _format_time(self, ms: int) -> str:
"""Format time in milliseconds to MM:SS"""
seconds = ms // 1000
minutes = seconds // 60
seconds = seconds % 60
return f"{minutes:02d}:{seconds:02d}"
class PlayerWindow(QMainWindow):
"""Main player window"""
def __init__(self, settings: QtConfig, overlay_engine: OverlayEngine):
super().__init__()
self.settings = settings
self.overlay_engine = overlay_engine
self.setup_ui()
self.setup_media_player()
# Auto-hide controls timer
self.controls_timer = QTimer()
self.controls_timer.timeout.connect(self.hide_controls)
self.controls_timer.setSingleShot(True)
# Mouse tracking for showing controls
self.setMouseTracking(True)
self.controls_visible = True
def setup_ui(self):
"""Setup window UI"""
self.setWindowTitle("MbetterClient Player")
self.setStyleSheet("background-color: black;")
# Central widget
central_widget = QWidget()
self.setCentralWidget(central_widget)
# Layout
layout = QVBoxLayout(central_widget)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Video widget
self.video_widget = VideoWidget()
self.video_widget.set_overlay_engine(self.overlay_engine)
layout.addWidget(self.video_widget, 1) # Stretch
# Controls
self.controls = PlayerControlsWidget()
self.controls.play_pause_clicked.connect(self.toggle_play_pause)
self.controls.stop_clicked.connect(self.stop_playback)
self.controls.seek_requested.connect(self.seek_to_position)
layout.addWidget(self.controls)
# Window settings
if self.settings.fullscreen:
self.showFullScreen()
else:
self.resize(self.settings.window_width, self.settings.window_height)
self.show()
if self.settings.always_on_top:
self.setWindowFlags(self.windowFlags() | Qt.WindowStaysOnTopHint)
def setup_media_player(self):
"""Setup media player"""
self.media_player = QMediaPlayer(None, QMediaPlayer.VideoSurface)
self.media_player.setVideoOutput(self.video_widget)
# Connect signals
self.media_player.stateChanged.connect(self.on_state_changed)
self.media_player.positionChanged.connect(self.on_position_changed)
self.media_player.durationChanged.connect(self.on_duration_changed)
self.media_player.error.connect(self.on_media_error)
# Set volume
self.media_player.setVolume(int(self.settings.volume * 100))
self.media_player.setMuted(self.settings.mute)
def play_video(self, file_path: str):
"""Play video file"""
try:
url = QUrl.fromLocalFile(str(Path(file_path).absolute()))
content = QMediaContent(url)
self.media_player.setMedia(content)
if self.settings.auto_play:
self.media_player.play()
logger.info(f"Playing video: {file_path}")
except Exception as e:
logger.error(f"Failed to play video: {e}")
def toggle_play_pause(self):
"""Toggle play/pause"""
if self.media_player.state() == QMediaPlayer.PlayingState:
self.media_player.pause()
else:
self.media_player.play()
def stop_playback(self):
"""Stop playback"""
self.media_player.stop()
def seek_to_position(self, percentage: int):
"""Seek to position (percentage)"""
duration = self.media_player.duration()
if duration > 0:
position = int(duration * percentage / 100)
self.media_player.setPosition(position)
def on_state_changed(self, state):
"""Handle state changes"""
is_playing = state == QMediaPlayer.PlayingState
self.controls.update_play_pause_button(is_playing)
# Auto-hide controls when playing in fullscreen
if is_playing and self.settings.fullscreen:
self.start_controls_timer()
def on_position_changed(self, position):
"""Handle position changes"""
duration = self.media_player.duration()
self.controls.update_position(position, duration)
# Update overlay engine with current position
if self.overlay_engine:
self.overlay_engine.update_playback_position(position, duration)
# Trigger overlay repaint
self.video_widget.update()
def on_duration_changed(self, duration):
"""Handle duration changes"""
self.controls.update_position(self.media_player.position(), duration)
def on_media_error(self, error):
"""Handle media errors"""
logger.error(f"Media player error: {error}")
def mouseMoveEvent(self, event):
"""Show controls on mouse movement"""
super().mouseMoveEvent(event)
self.show_controls()
if self.settings.fullscreen and self.media_player.state() == QMediaPlayer.PlayingState:
self.start_controls_timer()
def keyPressEvent(self, event):
"""Handle key presses"""
if event.key() == Qt.Key_Space:
self.toggle_play_pause()
elif event.key() == Qt.Key_Escape:
if self.isFullScreen():
self.showNormal()
else:
self.close()
elif event.key() == Qt.Key_F11:
if self.isFullScreen():
self.showNormal()
else:
self.showFullScreen()
elif event.key() == Qt.Key_M:
self.media_player.setMuted(not self.media_player.isMuted())
super().keyPressEvent(event)
def show_controls(self):
"""Show controls"""
if not self.controls_visible:
self.controls.show()
self.controls_visible = True
self.setCursor(Qt.ArrowCursor)
def hide_controls(self):
"""Hide controls"""
if self.settings.fullscreen and self.controls_visible:
self.controls.hide()
self.controls_visible = False
self.setCursor(Qt.BlankCursor)
def start_controls_timer(self):
"""Start timer to hide controls"""
self.controls_timer.stop()
self.controls_timer.start(3000) # Hide after 3 seconds
def closeEvent(self, event):
"""Handle window close"""
self.media_player.stop()
event.accept()
class QtVideoPlayer(ThreadedComponent):
"""Qt video player component"""
def __init__(self, message_bus: MessageBus, settings: QtConfig):
super().__init__("qt_player", message_bus)
self.settings = settings
self.app: Optional[QApplication] = None
self.window: Optional[PlayerWindow] = None
self.overlay_engine: Optional[OverlayEngine] = None
self.template_manager: Optional[TemplateManager] = None
# Register message queue
self.message_queue = self.message_bus.register_component(self.name)
logger.info("QtVideoPlayer initialized")
def initialize(self) -> bool:
"""Initialize PyQt application and components"""
try:
# Create QApplication if it doesn't exist
if not QApplication.instance():
self.app = QApplication(sys.argv)
self.app.setApplicationName("MbetterClient")
self.app.setQuitOnLastWindowClosed(True)
else:
self.app = QApplication.instance()
# Initialize overlay engine
self.overlay_engine = OverlayEngine()
# Initialize template manager
self.template_manager = TemplateManager()
# Create player window
self.window = PlayerWindow(self.settings, self.overlay_engine)
# Subscribe to messages
self.message_bus.subscribe(self.name, MessageType.VIDEO_PLAY, self._handle_video_play)
self.message_bus.subscribe(self.name, MessageType.VIDEO_PAUSE, self._handle_video_pause)
self.message_bus.subscribe(self.name, MessageType.VIDEO_STOP, self._handle_video_stop)
self.message_bus.subscribe(self.name, MessageType.VIDEO_SEEK, self._handle_video_seek)
self.message_bus.subscribe(self.name, MessageType.TEMPLATE_CHANGE, self._handle_template_change)
self.message_bus.subscribe(self.name, MessageType.OVERLAY_UPDATE, self._handle_overlay_update)
logger.info("QtVideoPlayer initialized successfully")
return True
except Exception as e:
logger.error(f"QtVideoPlayer initialization failed: {e}")
return False
def run(self):
"""Main run loop"""
try:
logger.info("QtVideoPlayer thread started")
# Send ready status
ready_message = MessageBuilder.system_status(
sender=self.name,
status="ready",
details={"fullscreen": self.settings.fullscreen}
)
self.message_bus.publish(ready_message)
# Message processing loop
while self.running:
try:
# Process Qt events
if self.app:
self.app.processEvents()
# Process messages
message = self.message_bus.get_message(self.name, timeout=0.1)
if message:
self._process_message(message)
# Send periodic progress updates if playing
if (self.window and self.window.media_player.state() == QMediaPlayer.PlayingState):
self._send_progress_update()
# Update heartbeat
self.heartbeat()
time.sleep(0.016) # ~60 FPS
except Exception as e:
logger.error(f"QtVideoPlayer run loop error: {e}")
time.sleep(0.1)
except Exception as e:
logger.error(f"QtVideoPlayer run failed: {e}")
finally:
logger.info("QtVideoPlayer thread ended")
def shutdown(self):
"""Shutdown video player"""
try:
logger.info("Shutting down QtVideoPlayer...")
if self.window:
self.window.close()
self.window = None
if self.app:
self.app.quit()
except Exception as e:
logger.error(f"QtVideoPlayer shutdown error: {e}")
def _process_message(self, message: Message):
"""Process received message"""
try:
# Messages are handled by subscribed handlers
pass
except Exception as e:
logger.error(f"Failed to process message: {e}")
def _handle_video_play(self, message: Message):
"""Handle video play message"""
try:
file_path = message.data.get("file_path")
template = message.data.get("template", "news_template")
overlay_data = message.data.get("overlay_data", {})
if not file_path:
logger.error("No file path provided for video play")
return
logger.info(f"Playing video: {file_path} with template: {template}")
# Load template
if self.template_manager:
template_config = self.template_manager.get_template(template)
if template_config:
self.overlay_engine.load_template(template_config, overlay_data)
# Play video
if self.window:
self.window.play_video(file_path)
except Exception as e:
logger.error(f"Failed to handle video play: {e}")
def _handle_video_pause(self, message: Message):
"""Handle video pause message"""
try:
if self.window:
self.window.media_player.pause()
except Exception as e:
logger.error(f"Failed to handle video pause: {e}")
def _handle_video_stop(self, message: Message):
"""Handle video stop message"""
try:
if self.window:
self.window.stop_playback()
except Exception as e:
logger.error(f"Failed to handle video stop: {e}")
def _handle_video_seek(self, message: Message):
"""Handle video seek message"""
try:
position = message.data.get("position", 0)
if self.window:
duration = self.window.media_player.duration()
if duration > 0:
percentage = int(position * 100 / duration)
self.window.seek_to_position(percentage)
except Exception as e:
logger.error(f"Failed to handle video seek: {e}")
def _handle_template_change(self, message: Message):
"""Handle template change message"""
try:
template_name = message.data.get("template_name")
template_data = message.data.get("template_data", {})
if self.template_manager and template_name:
template_config = self.template_manager.get_template(template_name)
if template_config and self.overlay_engine:
self.overlay_engine.load_template(template_config, template_data)
except Exception as e:
logger.error(f"Failed to handle template change: {e}")
def _handle_overlay_update(self, message: Message):
"""Handle overlay update message"""
try:
overlay_data = message.data.get("overlay_data", {})
if self.overlay_engine:
self.overlay_engine.update_overlay_data(overlay_data)
except Exception as e:
logger.error(f"Failed to handle overlay update: {e}")
def _send_progress_update(self):
"""Send video progress update"""
try:
if self.window and self.window.media_player.duration() > 0:
position = self.window.media_player.position()
duration = self.window.media_player.duration()
percentage = (position / duration) * 100 if duration > 0 else 0
progress_message = MessageBuilder.video_progress(
sender=self.name,
position=position / 1000.0, # Convert to seconds
duration=duration / 1000.0, # Convert to seconds
percentage=percentage
)
self.message_bus.publish(progress_message, broadcast=True)
except Exception as e:
logger.error(f"Failed to send progress update: {e}")
\ No newline at end of file
"""
Template management system for video overlays
"""
import logging
from typing import Dict, Any, Optional, List
from pathlib import Path
import json
logger = logging.getLogger(__name__)
class TemplateManager:
"""Manages overlay templates"""
def __init__(self):
self.templates: Dict[str, Dict[str, Any]] = {}
self._load_built_in_templates()
def _load_built_in_templates(self):
"""Load built-in templates"""
try:
# News template
self.templates['news_template'] = NewsTemplate().get_config()
# Sports template
self.templates['sports_template'] = SportsTemplate().get_config()
# Simple template
self.templates['simple_template'] = SimpleTemplate().get_config()
logger.info(f"Loaded {len(self.templates)} built-in templates")
except Exception as e:
logger.error(f"Failed to load built-in templates: {e}")
def get_template(self, template_name: str) -> Optional[Dict[str, Any]]:
"""Get template configuration by name"""
return self.templates.get(template_name)
def register_template(self, name: str, template_config: Dict[str, Any]):
"""Register a new template"""
self.templates[name] = template_config
logger.info(f"Registered template: {name}")
def get_template_names(self) -> List[str]:
"""Get list of available template names"""
return list(self.templates.keys())
def load_template_from_file(self, file_path: str) -> Optional[Dict[str, Any]]:
"""Load template from JSON file"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
template_config = json.load(f)
# Validate template
if self._validate_template(template_config):
return template_config
else:
logger.error(f"Invalid template in file: {file_path}")
return None
except Exception as e:
logger.error(f"Failed to load template from {file_path}: {e}")
return None
def save_template_to_file(self, template_config: Dict[str, Any], file_path: str) -> bool:
"""Save template to JSON file"""
try:
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(template_config, f, indent=2, ensure_ascii=False)
logger.info(f"Template saved to {file_path}")
return True
except Exception as e:
logger.error(f"Failed to save template to {file_path}: {e}")
return False
def _validate_template(self, template_config: Dict[str, Any]) -> bool:
"""Validate template configuration"""
try:
# Check required fields
required_fields = ['name', 'layout', 'elements']
for field in required_fields:
if field not in template_config:
logger.error(f"Template missing required field: {field}")
return False
# Validate layout
layout = template_config['layout']
if not isinstance(layout, dict):
logger.error("Template layout must be a dictionary")
return False
if 'width' not in layout or 'height' not in layout:
logger.error("Template layout must specify width and height")
return False
# Validate elements
elements = template_config['elements']
if not isinstance(elements, list):
logger.error("Template elements must be a list")
return False
for element in elements:
if not self._validate_element(element):
return False
return True
except Exception as e:
logger.error(f"Template validation error: {e}")
return False
def _validate_element(self, element: Dict[str, Any]) -> bool:
"""Validate template element"""
try:
# Check required fields
required_fields = ['id', 'type']
for field in required_fields:
if field not in element:
logger.error(f"Element missing required field: {field}")
return False
# Check element type
valid_types = ['rectangle', 'text', 'image']
if element['type'] not in valid_types:
logger.error(f"Invalid element type: {element['type']}")
return False
return True
except Exception as e:
logger.error(f"Element validation error: {e}")
return False
class BaseTemplate:
"""Base class for templates"""
def __init__(self, name: str, width: int = 1920, height: int = 1080):
self.name = name
self.width = width
self.height = height
self.elements = []
def get_config(self) -> Dict[str, Any]:
"""Get template configuration"""
return {
'name': self.name,
'layout': {
'width': self.width,
'height': self.height
},
'elements': self.elements
}
def add_element(self, element: Dict[str, Any]):
"""Add element to template"""
self.elements.append(element)
class NewsTemplate(BaseTemplate):
"""News-style template with scrolling text and boxing athletes image"""
def __init__(self):
super().__init__('news_template', 1920, 1080)
self._create_elements()
def _create_elements(self):
"""Create news template elements"""
# News bar background
self.add_element({
'id': 'news_bar',
'type': 'rectangle',
'x': 0,
'y': 950,
'width': 1920,
'height': 130,
'color': '#1a1a1a',
'opacity': 0.9,
'border_width': 2,
'border_color': '#333333',
'z_index': 1
})
# Boxing athletes image
self.add_element({
'id': 'athlete_image',
'type': 'image',
'x': 50,
'y': 960,
'width': 110,
'height': 110,
'source': 'assets/boxing_athletes.png',
'fit': 'cover',
'z_index': 2
})
# Scrolling news text
self.add_element({
'id': 'scrolling_text',
'type': 'text',
'x': 180,
'y': 985,
'width': 1500,
'height': 50,
'text': 'Breaking News: Boxing Match Updates • Live from the Arena',
'font_family': 'Arial',
'font_size': 28,
'font_weight': 'bold',
'color': '#ffffff',
'background_color': 'transparent',
'alignment': 'left',
'animation': {
'type': 'scroll',
'direction': 'left',
'speed': 120
},
'z_index': 3
})
# News ticker separator
self.add_element({
'id': 'ticker_separator',
'type': 'text',
'x': 180,
'y': 1030,
'width': 1600,
'height': 35,
'text': '• LIVE • BREAKING NEWS • SPORTS UPDATE •',
'font_family': 'Arial',
'font_size': 18,
'font_weight': 'normal',
'color': '#ffcc00',
'background_color': 'transparent',
'alignment': 'left',
'animation': {
'type': 'scroll',
'direction': 'left',
'speed': 80
},
'z_index': 3
})
# Optional logo area
self.add_element({
'id': 'logo_area',
'type': 'rectangle',
'x': 1750,
'y': 960,
'width': 150,
'height': 110,
'color': '#333333',
'opacity': 0.8,
'border_width': 1,
'border_color': '#555555',
'z_index': 2
})
# Logo text placeholder
self.add_element({
'id': 'logo_text',
'type': 'text',
'x': 1750,
'y': 1000,
'width': 150,
'height': 30,
'text': 'MBETTER',
'font_family': 'Arial',
'font_size': 16,
'font_weight': 'bold',
'color': '#ffffff',
'alignment': 'center',
'z_index': 3
})
class SportsTemplate(BaseTemplate):
"""Sports-focused template"""
def __init__(self):
super().__init__('sports_template', 1920, 1080)
self._create_elements()
def _create_elements(self):
"""Create sports template elements"""
# Score bar
self.add_element({
'id': 'score_bar',
'type': 'rectangle',
'x': 0,
'y': 50,
'width': 1920,
'height': 80,
'color': '#0066cc',
'opacity': 0.9,
'z_index': 1
})
# Team scores
self.add_element({
'id': 'team_scores',
'type': 'text',
'x': 50,
'y': 60,
'width': 1820,
'height': 60,
'text': 'TEAM A 2 - 1 TEAM B',
'font_family': 'Arial',
'font_size': 36,
'font_weight': 'bold',
'color': '#ffffff',
'alignment': 'center',
'z_index': 2
})
# Match time
self.add_element({
'id': 'match_time',
'type': 'text',
'x': 1700,
'y': 20,
'width': 200,
'height': 30,
'text': '45:30',
'font_family': 'Arial',
'font_size': 24,
'font_weight': 'bold',
'color': '#ffcc00',
'alignment': 'center',
'z_index': 2
})
class SimpleTemplate(BaseTemplate):
"""Simple template with minimal overlay"""
def __init__(self):
super().__init__('simple_template', 1920, 1080)
self._create_elements()
def _create_elements(self):
"""Create simple template elements"""
# Simple text overlay
self.add_element({
'id': 'simple_text',
'type': 'text',
'x': 50,
'y': 50,
'width': 800,
'height': 60,
'text': 'MbetterClient Video Player',
'font_family': 'Arial',
'font_size': 32,
'font_weight': 'bold',
'color': '#ffffff',
'background_color': 'rgba(0, 0, 0, 128)',
'alignment': 'left',
'z_index': 1
})
# Timestamp
self.add_element({
'id': 'timestamp',
'type': 'text',
'x': 1520,
'y': 50,
'width': 350,
'height': 40,
'text': '2024-01-01 12:00:00',
'font_family': 'Arial',
'font_size': 18,
'font_weight': 'normal',
'color': '#cccccc',
'alignment': 'right',
'z_index': 1
})
class TemplateBuilder:
"""Helper class for building custom templates"""
def __init__(self, name: str, width: int = 1920, height: int = 1080):
self.template = BaseTemplate(name, width, height)
def add_rectangle(self, element_id: str, x: int, y: int, width: int, height: int,
color: str = '#000000', opacity: float = 1.0, **kwargs) -> 'TemplateBuilder':
"""Add rectangle element"""
element = {
'id': element_id,
'type': 'rectangle',
'x': x,
'y': y,
'width': width,
'height': height,
'color': color,
'opacity': opacity,
**kwargs
}
self.template.add_element(element)
return self
def add_text(self, element_id: str, x: int, y: int, width: int, height: int,
text: str, font_size: int = 16, color: str = '#ffffff', **kwargs) -> 'TemplateBuilder':
"""Add text element"""
element = {
'id': element_id,
'type': 'text',
'x': x,
'y': y,
'width': width,
'height': height,
'text': text,
'font_size': font_size,
'color': color,
**kwargs
}
self.template.add_element(element)
return self
def add_image(self, element_id: str, x: int, y: int, width: int, height: int,
source: str, fit: str = 'contain', **kwargs) -> 'TemplateBuilder':
"""Add image element"""
element = {
'id': element_id,
'type': 'image',
'x': x,
'y': y,
'width': width,
'height': height,
'source': source,
'fit': fit,
**kwargs
}
self.template.add_element(element)
return self
def add_scrolling_text(self, element_id: str, x: int, y: int, width: int, height: int,
text: str, speed: int = 100, direction: str = 'left', **kwargs) -> 'TemplateBuilder':
"""Add scrolling text element"""
element = {
'id': element_id,
'type': 'text',
'x': x,
'y': y,
'width': width,
'height': height,
'text': text,
'animation': {
'type': 'scroll',
'direction': direction,
'speed': speed
},
**kwargs
}
self.template.add_element(element)
return self
def build(self) -> Dict[str, Any]:
"""Build and return template configuration"""
return self.template.get_config()
# Template presets for easy customization
NEWS_TEMPLATE_PRESET = {
'bar_height': 130,
'bar_opacity': 0.9,
'bar_color': '#1a1a1a',
'text_color': '#ffffff',
'accent_color': '#ffcc00',
'font_family': 'Arial',
'main_font_size': 28,
'ticker_font_size': 18,
'scroll_speed': 120,
'image_size': 110
}
SPORTS_TEMPLATE_PRESET = {
'bar_height': 80,
'bar_color': '#0066cc',
'text_color': '#ffffff',
'accent_color': '#ffcc00',
'font_family': 'Arial',
'score_font_size': 36,
'time_font_size': 24
}
def create_custom_news_template(preset: Dict[str, Any] = None) -> Dict[str, Any]:
"""Create customized news template using preset values"""
if preset is None:
preset = NEWS_TEMPLATE_PRESET
builder = TemplateBuilder('custom_news_template')
# News bar
builder.add_rectangle(
'news_bar', 0, 1080 - preset['bar_height'], 1920, preset['bar_height'],
color=preset['bar_color'], opacity=preset['bar_opacity']
)
# Boxing athletes image
builder.add_image(
'athlete_image', 50, 1080 - preset['bar_height'] + 10,
preset['image_size'], preset['image_size'],
source='assets/boxing_athletes.png', fit='cover'
)
# Scrolling text
builder.add_scrolling_text(
'scrolling_text', 50 + preset['image_size'] + 20, 1080 - preset['bar_height'] + 25,
1400, 50, text='Breaking News: Boxing Match Updates',
speed=preset['scroll_speed'], font_size=preset['main_font_size'],
color=preset['text_color'], font_family=preset['font_family'], font_weight='bold'
)
return builder.build()
def create_custom_sports_template(preset: Dict[str, Any] = None) -> Dict[str, Any]:
"""Create customized sports template using preset values"""
if preset is None:
preset = SPORTS_TEMPLATE_PRESET
builder = TemplateBuilder('custom_sports_template')
# Score bar
builder.add_rectangle(
'score_bar', 0, 50, 1920, preset['bar_height'],
color=preset['bar_color'], opacity=0.9
)
# Team scores
builder.add_text(
'team_scores', 50, 60, 1820, 60,
text='TEAM A 0 - 0 TEAM B',
font_size=preset['score_font_size'], color=preset['text_color'],
font_family=preset['font_family'], font_weight='bold', alignment='center'
)
# Match time
builder.add_text(
'match_time', 1700, 20, 200, 30,
text='00:00', font_size=preset['time_font_size'],
color=preset['accent_color'], font_family=preset['font_family'],
font_weight='bold', alignment='center'
)
return builder.build()
\ No newline at end of file
"""
Utility functions and classes for MbetterClient
"""
from .logger import setup_logging, get_logger
from .helpers import ensure_directory, safe_filename, format_bytes, format_duration
__all__ = [
'setup_logging',
'get_logger',
'ensure_directory',
'safe_filename',
'format_bytes',
'format_duration'
]
\ No newline at end of file
"""
Helper utility functions for MbetterClient
"""
import os
import re
import time
import hashlib
import platform
from pathlib import Path
from typing import Optional, Dict, Any, Union
def ensure_directory(path: Union[str, Path], create: bool = True) -> Path:
"""Ensure directory exists and return Path object"""
path_obj = Path(path)
if create and not path_obj.exists():
path_obj.mkdir(parents=True, exist_ok=True)
return path_obj
def safe_filename(filename: str, max_length: int = 255) -> str:
"""Convert filename to safe format for filesystem"""
# Remove or replace dangerous characters
filename = re.sub(r'[<>:"/\\|?*]', '_', filename)
filename = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', filename) # Remove control characters
filename = filename.strip('. ') # Remove leading/trailing dots and spaces
# Truncate if too long
if len(filename) > max_length:
name, ext = os.path.splitext(filename)
max_name_length = max_length - len(ext)
filename = name[:max_name_length] + ext
# Ensure not empty
if not filename:
filename = "untitled"
return filename
def format_bytes(bytes_value: int, decimal_places: int = 2) -> str:
"""Format bytes into human readable string"""
if bytes_value == 0:
return "0 B"
size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
i = 0
while bytes_value >= 1024 and i < len(size_names) - 1:
bytes_value /= 1024.0
i += 1
return f"{bytes_value:.{decimal_places}f} {size_names[i]}"
def format_duration(seconds: float, show_milliseconds: bool = False) -> str:
"""Format duration in seconds to human readable string"""
if seconds < 0:
return "0s"
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = seconds % 60
parts = []
if hours > 0:
parts.append(f"{hours}h")
if minutes > 0:
parts.append(f"{minutes}m")
if show_milliseconds:
parts.append(f"{secs:.3f}s")
else:
if secs > 0 or not parts: # Always show seconds if no other parts
parts.append(f"{int(secs)}s")
return " ".join(parts)
def calculate_file_hash(file_path: Union[str, Path], algorithm: str = "md5") -> str:
"""Calculate hash of file"""
hash_func = hashlib.new(algorithm)
with open(file_path, 'rb') as f:
while chunk := f.read(8192):
hash_func.update(chunk)
return hash_func.hexdigest()
def get_system_info() -> Dict[str, Any]:
"""Get system information"""
return {
"platform": platform.platform(),
"system": platform.system(),
"release": platform.release(),
"version": platform.version(),
"machine": platform.machine(),
"processor": platform.processor(),
"python_version": platform.python_version(),
"python_implementation": platform.python_implementation(),
}
def is_port_available(port: int, host: str = "localhost") -> bool:
"""Check if port is available"""
import socket
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(1)
result = sock.connect_ex((host, port))
return result != 0 # Port is available if connection failed
except Exception:
return False
def find_available_port(start_port: int = 5001, max_attempts: int = 100,
host: str = "localhost") -> Optional[int]:
"""Find first available port starting from start_port"""
for port in range(start_port, start_port + max_attempts):
if is_port_available(port, host):
return port
return None
def validate_url(url: str) -> bool:
"""Validate URL format"""
import re
url_pattern = re.compile(
r'^https?://' # http:// or https://
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' # domain...
r'localhost|' # localhost...
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip
r'(?::\d+)?' # optional port
r'(?:/?|[/?]\S+)$', re.IGNORECASE)
return url_pattern.match(url) is not None
def retry_on_failure(max_attempts: int = 3, delay: float = 1.0,
exceptions: tuple = (Exception,)):
"""Decorator for retrying function calls on failure"""
def decorator(func):
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exception = e
if attempt < max_attempts - 1:
time.sleep(delay)
continue
raise last_exception
return wrapper
return decorator
def truncate_string(text: str, max_length: int, suffix: str = "...") -> str:
"""Truncate string to max length with suffix"""
if len(text) <= max_length:
return text
return text[:max_length - len(suffix)] + suffix
def deep_merge_dicts(dict1: Dict[str, Any], dict2: Dict[str, Any]) -> Dict[str, Any]:
"""Deep merge two dictionaries"""
result = dict1.copy()
for key, value in dict2.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = deep_merge_dicts(result[key], value)
else:
result[key] = value
return result
def flatten_dict(d: Dict[str, Any], parent_key: str = '', sep: str = '.') -> Dict[str, Any]:
"""Flatten nested dictionary"""
items = []
for k, v in d.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
if isinstance(v, dict):
items.extend(flatten_dict(v, new_key, sep=sep).items())
else:
items.append((new_key, v))
return dict(items)
def unflatten_dict(d: Dict[str, Any], sep: str = '.') -> Dict[str, Any]:
"""Unflatten dictionary with dot notation keys"""
result = {}
for key, value in d.items():
keys = key.split(sep)
current = result
for k in keys[:-1]:
if k not in current:
current[k] = {}
current = current[k]
current[keys[-1]] = value
return result
def get_file_mime_type(file_path: Union[str, Path]) -> str:
"""Get MIME type of file"""
import mimetypes
mime_type, _ = mimetypes.guess_type(str(file_path))
return mime_type or "application/octet-stream"
def is_video_file(file_path: Union[str, Path]) -> bool:
"""Check if file is a video file"""
mime_type = get_file_mime_type(file_path)
return mime_type.startswith('video/')
def is_image_file(file_path: Union[str, Path]) -> bool:
"""Check if file is an image file"""
mime_type = get_file_mime_type(file_path)
return mime_type.startswith('image/')
def sanitize_json(obj: Any) -> Any:
"""Sanitize object for JSON serialization"""
if isinstance(obj, dict):
return {k: sanitize_json(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [sanitize_json(item) for item in obj]
elif isinstance(obj, (str, int, float, bool)) or obj is None:
return obj
else:
return str(obj)
def get_local_ip() -> str:
"""Get local IP address"""
import socket
try:
# Connect to a remote address to get local IP
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except Exception:
return "127.0.0.1"
def parse_duration_string(duration_str: str) -> float:
"""Parse duration string like '1h30m15s' to seconds"""
import re
# Pattern to match hours, minutes, seconds
pattern = r'(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?'
match = re.match(pattern, duration_str.strip())
if not match:
raise ValueError(f"Invalid duration format: {duration_str}")
hours, minutes, seconds = match.groups()
total_seconds = 0.0
if hours:
total_seconds += int(hours) * 3600
if minutes:
total_seconds += int(minutes) * 60
if seconds:
total_seconds += float(seconds)
return total_seconds
class Timer:
"""Simple timer context manager"""
def __init__(self, description: str = "Operation"):
self.description = description
self.start_time = None
self.end_time = None
def __enter__(self):
self.start_time = time.time()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.end_time = time.time()
@property
def elapsed(self) -> float:
"""Get elapsed time in seconds"""
if self.start_time is None:
return 0.0
end_time = self.end_time or time.time()
return end_time - self.start_time
def __str__(self):
return f"{self.description}: {format_duration(self.elapsed)}"
class RateLimiter:
"""Simple rate limiter"""
def __init__(self, max_calls: int, time_window: float):
self.max_calls = max_calls
self.time_window = time_window
self.calls = []
def can_proceed(self) -> bool:
"""Check if call can proceed"""
now = time.time()
# Remove old calls outside time window
self.calls = [call_time for call_time in self.calls
if now - call_time < self.time_window]
# Check if under limit
if len(self.calls) < self.max_calls:
self.calls.append(now)
return True
return False
def wait_time(self) -> float:
"""Get time to wait before next call can proceed"""
if not self.calls:
return 0.0
oldest_call = min(self.calls)
wait_time = self.time_window - (time.time() - oldest_call)
return max(0.0, wait_time)
\ No newline at end of file
"""
Logging utilities for MbetterClient
"""
import os
import logging
import logging.handlers
from pathlib import Path
from typing import Optional
from loguru import logger as loguru_logger
def setup_logging(level: int = logging.INFO, log_file: Optional[str] = None,
component: Optional[str] = None) -> logging.Logger:
"""Setup application logging with file and console handlers"""
# Create logger name
logger_name = f"mbetterclient"
if component:
logger_name += f".{component}"
# Get logger
logger = logging.getLogger(logger_name)
logger.setLevel(level)
# Clear existing handlers
logger.handlers.clear()
# Create formatters
console_formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
file_formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
# Console handler
console_handler = logging.StreamHandler()
console_handler.setLevel(level)
console_handler.setFormatter(console_formatter)
logger.addHandler(console_handler)
# File handler
if log_file:
# Ensure log directory exists
log_path = Path(log_file)
log_path.parent.mkdir(parents=True, exist_ok=True)
# Rotating file handler
file_handler = logging.handlers.RotatingFileHandler(
log_file,
maxBytes=10 * 1024 * 1024, # 10MB
backupCount=5,
encoding='utf-8'
)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(file_formatter)
logger.addHandler(file_handler)
# Prevent propagation to root logger
logger.propagate = False
return logger
def get_logger(name: str = "mbetterclient") -> logging.Logger:
"""Get existing logger instance"""
return logging.getLogger(name)
def setup_loguru_logging(log_file: Optional[str] = None, level: str = "INFO") -> None:
"""Setup loguru-based logging as alternative"""
# Remove default handler
loguru_logger.remove()
# Console logging
loguru_logger.add(
sink=lambda message: print(message, end=""),
level=level,
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
"<level>{level: <8}</level> | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | "
"<level>{message}</level>",
colorize=True
)
# File logging
if log_file:
log_path = Path(log_file)
log_path.parent.mkdir(parents=True, exist_ok=True)
loguru_logger.add(
sink=log_file,
level="DEBUG",
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} | {message}",
rotation="10 MB",
retention="7 days",
compression="zip",
encoding="utf-8"
)
class ComponentLogger:
"""Logger wrapper for components with automatic component name"""
def __init__(self, component_name: str, base_logger: Optional[logging.Logger] = None):
self.component_name = component_name
if base_logger:
self.logger = base_logger.getChild(component_name)
else:
self.logger = logging.getLogger(f"mbetterclient.{component_name}")
def debug(self, message: str, **kwargs):
"""Log debug message"""
self.logger.debug(f"[{self.component_name}] {message}", **kwargs)
def info(self, message: str, **kwargs):
"""Log info message"""
self.logger.info(f"[{self.component_name}] {message}", **kwargs)
def warning(self, message: str, **kwargs):
"""Log warning message"""
self.logger.warning(f"[{self.component_name}] {message}", **kwargs)
def error(self, message: str, **kwargs):
"""Log error message"""
self.logger.error(f"[{self.component_name}] {message}", **kwargs)
def critical(self, message: str, **kwargs):
"""Log critical message"""
self.logger.critical(f"[{self.component_name}] {message}", **kwargs)
def exception(self, message: str, **kwargs):
"""Log exception with traceback"""
self.logger.exception(f"[{self.component_name}] {message}", **kwargs)
\ No newline at end of file
"""
Web dashboard for MbetterClient with authentication and configuration
"""
from .app import WebDashboard
from .auth import AuthManager, jwt_required
from .api import DashboardAPI
__all__ = [
'WebDashboard',
'AuthManager',
'jwt_required',
'DashboardAPI'
]
\ No newline at end of file
"""
REST API for web dashboard
"""
import logging
import time
from datetime import datetime
from typing import Dict, Any, Optional, List
from flask import request, jsonify, g
from ..database.manager import DatabaseManager
from ..config.manager import ConfigManager
from ..core.message_bus import MessageBus, MessageBuilder, MessageType, Message
logger = logging.getLogger(__name__)
class DashboardAPI:
"""REST API endpoints for the dashboard"""
def __init__(self, db_manager: DatabaseManager, config_manager: ConfigManager,
message_bus: MessageBus):
self.db_manager = db_manager
self.config_manager = config_manager
self.message_bus = message_bus
logger.info("DashboardAPI initialized")
def get_system_status(self) -> Dict[str, Any]:
"""Get system status"""
try:
# Get configuration status
config_status = self.config_manager.validate_configuration()
# Get database status
db_status = self.db_manager.get_connection_status()
# Get component status (cached or from message bus)
components_status = self._get_components_status()
return {
"status": "online",
"timestamp": datetime.utcnow().isoformat(),
"uptime": time.time(), # Would be actual uptime in production
"config": config_status,
"database": db_status,
"components": components_status
}
except Exception as e:
logger.error(f"Failed to get system status: {e}")
return {
"status": "error",
"error": str(e),
"timestamp": datetime.utcnow().isoformat()
}
def get_video_status(self) -> Dict[str, Any]:
"""Get video player status"""
try:
# Request status from video player
status_request = Message(
type=MessageType.STATUS_REQUEST,
sender="web_dashboard",
recipient="qt_player",
data={}
)
self.message_bus.publish(status_request)
# For now, return basic status
# In full implementation, would wait for response or use cached status
return {
"player_status": "unknown",
"current_file": None,
"current_template": "news_template",
"position": 0,
"duration": 0,
"volume": 100,
"fullscreen": False
}
except Exception as e:
logger.error(f"Failed to get video status: {e}")
return {"error": str(e)}
def control_video(self, action: str, **kwargs) -> Dict[str, Any]:
"""Control video player"""
try:
success = False
if action == "play":
file_path = kwargs.get("file_path", "")
template = kwargs.get("template", "news_template")
overlay_data = kwargs.get("overlay_data", {})
message = MessageBuilder.video_play(
sender="web_dashboard",
file_path=file_path,
template=template,
overlay_data=overlay_data
)
message.recipient = "qt_player"
self.message_bus.publish(message)
success = True
elif action == "pause":
message = Message(
type=MessageType.VIDEO_PAUSE,
sender="web_dashboard",
recipient="qt_player",
data={}
)
self.message_bus.publish(message)
success = True
elif action == "stop":
message = Message(
type=MessageType.VIDEO_STOP,
sender="web_dashboard",
recipient="qt_player",
data={}
)
self.message_bus.publish(message)
success = True
elif action == "seek":
position = kwargs.get("position", 0)
message = Message(
type=MessageType.VIDEO_SEEK,
sender="web_dashboard",
recipient="qt_player",
data={"position": position}
)
self.message_bus.publish(message)
success = True
elif action == "volume":
volume = kwargs.get("volume", 100)
message = Message(
type=MessageType.VIDEO_VOLUME,
sender="web_dashboard",
recipient="qt_player",
data={"volume": volume}
)
self.message_bus.publish(message)
success = True
elif action == "fullscreen":
fullscreen = kwargs.get("fullscreen", True)
message = Message(
type=MessageType.VIDEO_FULLSCREEN,
sender="web_dashboard",
recipient="qt_player",
data={"fullscreen": fullscreen}
)
self.message_bus.publish(message)
success = True
else:
return {"error": f"Unknown action: {action}"}
if success:
logger.info(f"Video control command sent: {action}")
return {"success": True, "action": action}
else:
return {"error": "Failed to send command"}
except Exception as e:
logger.error(f"Video control error: {e}")
return {"error": str(e)}
def update_overlay(self, template: str, data: Dict[str, Any]) -> Dict[str, Any]:
"""Update video overlay"""
try:
message = MessageBuilder.template_change(
sender="web_dashboard",
template_name=template,
template_data=data
)
message.recipient = "qt_player"
self.message_bus.publish(message)
logger.info(f"Overlay update sent: {template}")
return {"success": True, "template": template}
except Exception as e:
logger.error(f"Overlay update error: {e}")
return {"error": str(e)}
def get_templates(self) -> Dict[str, Any]:
"""Get available overlay templates"""
try:
# This would normally query the template system
templates = {
"news_template": {
"name": "News Template",
"description": "Breaking news with scrolling text",
"fields": ["headline", "ticker_text", "logo_url"]
},
"sports_template": {
"name": "Sports Template",
"description": "Sports scores and updates",
"fields": ["team1", "team2", "score1", "score2", "event"]
},
"simple_template": {
"name": "Simple Template",
"description": "Basic text overlay",
"fields": ["title", "subtitle", "text"]
}
}
return {"templates": templates}
except Exception as e:
logger.error(f"Failed to get templates: {e}")
return {"error": str(e)}
def get_configuration(self, section: Optional[str] = None) -> Dict[str, Any]:
"""Get configuration data"""
try:
if section:
config_data = self.config_manager.get_section_config(section)
return {"section": section, "config": config_data}
else:
all_config = self.config_manager.get_all_config()
return {"config": all_config}
except Exception as e:
logger.error(f"Failed to get configuration: {e}")
return {"error": str(e)}
def update_configuration(self, section: str, config_data: Dict[str, Any]) -> Dict[str, Any]:
"""Update configuration"""
try:
# Update configuration
success = self.config_manager.update_section(section, config_data)
if success:
# Notify other components of configuration change
message = Message(
type=MessageType.CONFIG_UPDATE,
sender="web_dashboard",
data={
"config_section": section,
"config_data": config_data
}
)
self.message_bus.publish(message)
logger.info(f"Configuration updated: {section}")
return {"success": True, "section": section}
else:
return {"error": "Failed to update configuration"}
except Exception as e:
logger.error(f"Configuration update error: {e}")
return {"error": str(e)}
def get_logs(self, level: str = "INFO", limit: int = 100) -> Dict[str, Any]:
"""Get application logs"""
try:
# This would normally read from log files or database
# For now, return placeholder data
logs = [
{
"timestamp": datetime.utcnow().isoformat(),
"level": "INFO",
"component": "web_dashboard",
"message": "Dashboard started successfully"
},
{
"timestamp": datetime.utcnow().isoformat(),
"level": "INFO",
"component": "qt_player",
"message": "Video player initialized"
}
]
return {"logs": logs[:limit]}
except Exception as e:
logger.error(f"Failed to get logs: {e}")
return {"error": str(e)}
def get_users(self) -> Dict[str, Any]:
"""Get all users (admin only)"""
try:
users = self.db_manager.get_all_users()
user_list = [
{
"id": user.id,
"username": user.username,
"email": user.email,
"is_admin": user.is_admin,
"created_at": user.created_at.isoformat(),
"last_login": user.last_login.isoformat() if user.last_login else None
}
for user in users
]
return {"users": user_list}
except Exception as e:
logger.error(f"Failed to get users: {e}")
return {"error": str(e)}
def create_user(self, username: str, email: str, password: str,
is_admin: bool = False) -> Dict[str, Any]:
"""Create new user (admin only)"""
try:
from .auth import AuthManager
# Get auth manager from Flask g context
auth_manager = g.get('auth_manager')
if not auth_manager:
return {"error": "Auth manager not available"}
user = auth_manager.create_user(username, email, password, is_admin)
if user:
return {
"success": True,
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"is_admin": user.is_admin
}
}
else:
return {"error": "Failed to create user"}
except Exception as e:
logger.error(f"User creation error: {e}")
return {"error": str(e)}
def delete_user(self, user_id: int) -> Dict[str, Any]:
"""Delete user (admin only)"""
try:
success = self.db_manager.delete_user(user_id)
if success:
logger.info(f"User deleted: {user_id}")
return {"success": True}
else:
return {"error": "Failed to delete user"}
except Exception as e:
logger.error(f"User deletion error: {e}")
return {"error": str(e)}
def get_api_tokens(self, user_id: int) -> Dict[str, Any]:
"""Get API tokens for user"""
try:
from .auth import AuthManager
auth_manager = g.get('auth_manager')
if not auth_manager:
return {"error": "Auth manager not available"}
tokens = auth_manager.list_user_tokens(user_id)
return {"tokens": tokens}
except Exception as e:
logger.error(f"Failed to get API tokens: {e}")
return {"error": str(e)}
def create_api_token(self, user_id: int, token_name: str,
expires_hours: int = 8760) -> Dict[str, Any]:
"""Create API token"""
try:
from .auth import AuthManager
auth_manager = g.get('auth_manager')
if not auth_manager:
return {"error": "Auth manager not available"}
result = auth_manager.create_api_token(user_id, token_name, expires_hours)
if result:
token, token_record = result
return {
"success": True,
"token": token,
"token_info": {
"id": token_record.id,
"name": token_record.token_name,
"expires_at": token_record.expires_at.isoformat()
}
}
else:
return {"error": "Failed to create API token"}
except Exception as e:
logger.error(f"API token creation error: {e}")
return {"error": str(e)}
def revoke_api_token(self, user_id: int, token_id: int) -> Dict[str, Any]:
"""Revoke API token"""
try:
from .auth import AuthManager
auth_manager = g.get('auth_manager')
if not auth_manager:
return {"error": "Auth manager not available"}
success = auth_manager.revoke_api_token_by_id(token_id, user_id)
if success:
return {"success": True}
else:
return {"error": "Failed to revoke token"}
except Exception as e:
logger.error(f"API token revocation error: {e}")
return {"error": str(e)}
def _get_components_status(self) -> Dict[str, Any]:
"""Get status of all components"""
# This would normally maintain a cache of component status
# or query components through message bus
return {
"qt_player": "unknown",
"web_dashboard": "running",
"api_client": "unknown",
"message_bus": "running"
}
def send_test_message(self, recipient: str, message_type: str,
data: Dict[str, Any]) -> Dict[str, Any]:
"""Send test message to component (admin only)"""
try:
# Map string message types to enum
type_mapping = {
"video_play": MessageType.VIDEO_PLAY,
"video_pause": MessageType.VIDEO_PAUSE,
"video_stop": MessageType.VIDEO_STOP,
"config_update": MessageType.CONFIG_UPDATE,
"system_status": MessageType.SYSTEM_STATUS,
}
msg_type = type_mapping.get(message_type)
if not msg_type:
return {"error": f"Unknown message type: {message_type}"}
message = Message(
type=msg_type,
sender="web_dashboard",
recipient=recipient,
data=data
)
self.message_bus.publish(message)
logger.info(f"Test message sent: {message_type} to {recipient}")
return {"success": True, "message_type": message_type, "recipient": recipient}
except Exception as e:
logger.error(f"Test message error: {e}")
return {"error": str(e)}
\ No newline at end of file
"""
Flask web dashboard application for MbetterClient
"""
import time
import logging
from pathlib import Path
from typing import Optional, Dict, Any
from flask import Flask, request, jsonify, render_template, redirect, url_for, session, g
from flask_login import LoginManager, login_required, current_user
from flask_jwt_extended import JWTManager, create_access_token, jwt_required as flask_jwt_required
from werkzeug.serving import make_server
import threading
from ..core.thread_manager import ThreadedComponent
from ..core.message_bus import MessageBus, Message, MessageType, MessageBuilder
from ..config.settings import WebConfig
from ..config.manager import ConfigManager
from ..database.manager import DatabaseManager
from .auth import AuthManager
from .api import DashboardAPI
from .routes import main_bp, auth_bp, api_bp
logger = logging.getLogger(__name__)
class WebDashboard(ThreadedComponent):
"""Flask web dashboard component"""
def __init__(self, message_bus: MessageBus, db_manager: DatabaseManager,
config_manager: ConfigManager, settings: WebConfig):
super().__init__("web_dashboard", message_bus)
self.db_manager = db_manager
self.config_manager = config_manager
self.settings = settings
# Flask app and server
self.app: Optional[Flask] = None
self.server: Optional = None
self.auth_manager: Optional[AuthManager] = None
self.api: Optional[DashboardAPI] = None
# Register message queue
self.message_queue = self.message_bus.register_component(self.name)
logger.info("WebDashboard initialized")
def initialize(self) -> bool:
"""Initialize Flask application"""
try:
# Create Flask app
self.app = self._create_flask_app()
# Initialize auth manager
self.auth_manager = AuthManager(self.db_manager, self.app)
# Initialize API
self.api = DashboardAPI(self.db_manager, self.config_manager, self.message_bus)
# Setup routes
self._setup_routes()
# Create HTTP server
self._create_server()
# Subscribe to messages
self.message_bus.subscribe(self.name, MessageType.CONFIG_UPDATE, self._handle_config_update)
self.message_bus.subscribe(self.name, MessageType.SYSTEM_STATUS, self._handle_system_status)
logger.info("WebDashboard initialized successfully")
return True
except Exception as e:
logger.error(f"WebDashboard initialization failed: {e}")
return False
def _create_flask_app(self) -> Flask:
"""Create and configure Flask application"""
# Template and static directories
template_dir = Path(__file__).parent / 'templates'
static_dir = Path(__file__).parent / 'static'
# Create Flask app
app = Flask(__name__,
template_folder=str(template_dir),
static_folder=str(static_dir))
# Configuration
app.config.update({
'SECRET_KEY': self.settings.secret_key,
'JWT_SECRET_KEY': self.settings.jwt_secret_key,
'JWT_ACCESS_TOKEN_EXPIRES': self.settings.jwt_expiration_hours * 3600,
'SESSION_COOKIE_HTTPONLY': True,
'SESSION_COOKIE_SECURE': False, # Set to True when using HTTPS
'PERMANENT_SESSION_LIFETIME': self.settings.session_timeout_hours * 3600,
'WTF_CSRF_ENABLED': True,
'WTF_CSRF_TIME_LIMIT': None,
})
# Initialize extensions
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.'
jwt_manager = JWTManager(app)
# User loader for Flask-Login
@login_manager.user_loader
def load_user(user_id):
return self.db_manager.get_user_by_id(int(user_id))
# JWT error handlers
@jwt_manager.expired_token_loader
def expired_token_callback(jwt_header, jwt_payload):
return jsonify({'error': 'Token has expired'}), 401
@jwt_manager.invalid_token_loader
def invalid_token_callback(error):
return jsonify({'error': 'Invalid token'}), 401
@jwt_manager.unauthorized_loader
def unauthorized_callback(error):
return jsonify({'error': 'Authorization required'}), 401
# Request context setup
@app.before_request
def before_request():
g.db_manager = self.db_manager
g.config_manager = self.config_manager
g.message_bus = self.message_bus
g.auth_manager = self.auth_manager
g.api = self.api
# Template context processors
@app.context_processor
def inject_globals():
return {
'app_name': 'MbetterClient',
'app_version': '1.0.0',
'current_time': time.time(),
}
# Error handlers
@app.errorhandler(404)
def not_found_error(error):
return render_template('errors/404.html'), 404
@app.errorhandler(500)
def internal_error(error):
return render_template('errors/500.html'), 500
@app.errorhandler(403)
def forbidden_error(error):
return render_template('errors/403.html'), 403
return app
def _setup_routes(self):
"""Setup Flask routes"""
# Pass dependencies to route modules
main_bp.db_manager = self.db_manager
main_bp.config_manager = self.config_manager
main_bp.message_bus = self.message_bus
auth_bp.auth_manager = self.auth_manager
auth_bp.db_manager = self.db_manager
api_bp.api = self.api
api_bp.db_manager = self.db_manager
api_bp.config_manager = self.config_manager
api_bp.message_bus = self.message_bus
# Register blueprints
self.app.register_blueprint(main_bp)
self.app.register_blueprint(auth_bp, url_prefix='/auth')
self.app.register_blueprint(api_bp, url_prefix='/api')
def _create_server(self):
"""Create HTTP server"""
try:
self.server = make_server(
self.settings.host,
self.settings.port,
self.app,
threaded=True
)
logger.info(f"HTTP server created on {self.settings.host}:{self.settings.port}")
except Exception as e:
logger.error(f"Failed to create HTTP server: {e}")
raise
def run(self):
"""Main run loop"""
try:
logger.info("WebDashboard thread started")
# Send ready status
ready_message = MessageBuilder.system_status(
sender=self.name,
status="ready",
details={
"host": self.settings.host,
"port": self.settings.port,
"url": f"http://{self.settings.host}:{self.settings.port}"
}
)
self.message_bus.publish(ready_message)
# Start HTTP server in separate thread
server_thread = threading.Thread(
target=self._run_server,
name="WebServer",
daemon=True
)
server_thread.start()
# Message processing loop
while self.running:
try:
# Process messages
message = self.message_bus.get_message(self.name, timeout=1.0)
if message:
self._process_message(message)
# Update heartbeat
self.heartbeat()
time.sleep(0.1)
except Exception as e:
logger.error(f"WebDashboard run loop error: {e}")
time.sleep(1.0)
# Wait for server thread
if server_thread.is_alive():
server_thread.join(timeout=5.0)
except Exception as e:
logger.error(f"WebDashboard run failed: {e}")
finally:
logger.info("WebDashboard thread ended")
def _run_server(self):
"""Run HTTP server"""
try:
logger.info(f"Starting HTTP server on {self.settings.host}:{self.settings.port}")
self.server.serve_forever()
except Exception as e:
if self.running: # Only log if not shutting down
logger.error(f"HTTP server error: {e}")
def shutdown(self):
"""Shutdown web dashboard"""
try:
logger.info("Shutting down WebDashboard...")
if self.server:
self.server.shutdown()
except Exception as e:
logger.error(f"WebDashboard shutdown error: {e}")
def _process_message(self, message: Message):
"""Process received message"""
try:
# Messages are handled by subscribed handlers
pass
except Exception as e:
logger.error(f"Failed to process message: {e}")
def _handle_config_update(self, message: Message):
"""Handle configuration update message"""
try:
config_section = message.data.get("config_section")
config_data = message.data.get("config_data")
logger.info(f"Configuration update received for section: {config_section}")
# Update configuration through config manager
if config_section and config_data:
self.config_manager.update_from_web(config_data)
except Exception as e:
logger.error(f"Failed to handle config update: {e}")
def _handle_system_status(self, message: Message):
"""Handle system status message"""
try:
status = message.data.get("status")
sender = message.sender
logger.debug(f"System status from {sender}: {status}")
# Store status for web interface
# This could be cached or stored in database for display
except Exception as e:
logger.error(f"Failed to handle system status: {e}")
def get_app_context(self):
"""Get Flask application context"""
if self.app:
return self.app.app_context()
return None
def send_video_command(self, command: str, **kwargs) -> bool:
"""Send video command through message bus"""
try:
if command == "play":
message = MessageBuilder.video_play(
sender=self.name,
file_path=kwargs.get("file_path", ""),
template=kwargs.get("template", "news_template"),
overlay_data=kwargs.get("overlay_data", {})
)
elif command == "pause":
message = Message(
type=MessageType.VIDEO_PAUSE,
sender=self.name,
data={}
)
elif command == "stop":
message = Message(
type=MessageType.VIDEO_STOP,
sender=self.name,
data={}
)
elif command == "template_change":
message = MessageBuilder.template_change(
sender=self.name,
template_name=kwargs.get("template_name", "news_template"),
template_data=kwargs.get("template_data", {})
)
else:
logger.error(f"Unknown video command: {command}")
return False
# Send to qt_player
message.recipient = "qt_player"
self.message_bus.publish(message)
logger.info(f"Video command sent: {command}")
return True
except Exception as e:
logger.error(f"Failed to send video command: {e}")
return False
def get_system_status(self) -> Dict[str, Any]:
"""Get current system status"""
try:
# Request status from core
status_request = Message(
type=MessageType.CONFIG_REQUEST,
sender=self.name,
recipient="core",
data={"section": "status"}
)
self.message_bus.publish(status_request)
# For now, return basic status
# In a full implementation, this would wait for response or cache status
return {
"web_dashboard": "running",
"host": self.settings.host,
"port": self.settings.port,
"timestamp": time.time()
}
except Exception as e:
logger.error(f"Failed to get system status: {e}")
return {"error": str(e)}
def create_app(db_manager: DatabaseManager, config_manager: ConfigManager,
settings: WebConfig) -> Flask:
"""Factory function to create Flask app (for testing)"""
dashboard = WebDashboard(None, db_manager, config_manager, settings)
dashboard.initialize()
return dashboard.app
\ No newline at end of file
"""
Authentication manager for web dashboard
"""
import hashlib
import secrets
import logging
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, Tuple
from flask import Flask, request, session
from flask_login import UserMixin
from flask_jwt_extended import create_access_token, decode_token
import jwt
from ..database.manager import DatabaseManager
from ..database.models import User, APIToken
logger = logging.getLogger(__name__)
class AuthenticatedUser(UserMixin):
"""User class for Flask-Login"""
def __init__(self, user_id: int, username: str, email: str, is_admin: bool = False):
self.id = user_id
self.username = username
self.email = email
self.is_admin = is_admin
self.is_authenticated = True
self.is_active = True
self.is_anonymous = False
def get_id(self):
return str(self.id)
def to_dict(self) -> Dict[str, Any]:
return {
'id': self.id,
'username': self.username,
'email': self.email,
'is_admin': self.is_admin
}
class AuthManager:
"""Handles authentication, JWT tokens, and user management"""
def __init__(self, db_manager: DatabaseManager, app: Flask):
self.db_manager = db_manager
self.app = app
self.jwt_secret = app.config['JWT_SECRET_KEY']
self.session_timeout = app.config['PERMANENT_SESSION_LIFETIME']
logger.info("AuthManager initialized")
def hash_password(self, password: str) -> str:
"""Hash password using SHA-256 with salt"""
salt = secrets.token_hex(16)
password_hash = hashlib.sha256((password + salt).encode()).hexdigest()
return f"{salt}:{password_hash}"
def verify_password(self, password: str, stored_hash: str) -> bool:
"""Verify password against stored hash"""
try:
salt, password_hash = stored_hash.split(':', 1)
expected_hash = hashlib.sha256((password + salt).encode()).hexdigest()
return password_hash == expected_hash
except (ValueError, AttributeError):
return False
def authenticate_user(self, username: str, password: str) -> Optional[AuthenticatedUser]:
"""Authenticate user with username/password"""
try:
user = self.db_manager.get_user_by_username(username)
if not user:
logger.warning(f"Authentication failed: user not found - {username}")
return None
if not self.verify_password(password, user.password_hash):
logger.warning(f"Authentication failed: invalid password - {username}")
return None
# Update last login
user.last_login = datetime.utcnow()
self.db_manager.save_user(user)
authenticated_user = AuthenticatedUser(
user_id=user.id,
username=user.username,
email=user.email,
is_admin=user.is_admin
)
logger.info(f"User authenticated successfully: {username}")
return authenticated_user
except Exception as e:
logger.error(f"Authentication error: {e}")
return None
def create_user(self, username: str, email: str, password: str,
is_admin: bool = False) -> Optional[User]:
"""Create new user"""
try:
# Check if user already exists
existing_user = self.db_manager.get_user_by_username(username)
if existing_user:
logger.warning(f"User creation failed: username already exists - {username}")
return None
existing_email = self.db_manager.get_user_by_email(email)
if existing_email:
logger.warning(f"User creation failed: email already exists - {email}")
return None
# Create new user
password_hash = self.hash_password(password)
user = User(
username=username,
email=email,
password_hash=password_hash,
is_admin=is_admin,
created_at=datetime.utcnow()
)
saved_user = self.db_manager.save_user(user)
logger.info(f"User created successfully: {username}")
return saved_user
except Exception as e:
logger.error(f"User creation error: {e}")
return None
def change_password(self, user_id: int, old_password: str, new_password: str) -> bool:
"""Change user password"""
try:
user = self.db_manager.get_user_by_id(user_id)
if not user:
logger.warning(f"Password change failed: user not found - {user_id}")
return False
if not self.verify_password(old_password, user.password_hash):
logger.warning(f"Password change failed: invalid old password - {user_id}")
return False
# Update password
user.password_hash = self.hash_password(new_password)
user.updated_at = datetime.utcnow()
self.db_manager.save_user(user)
logger.info(f"Password changed successfully: {user.username}")
return True
except Exception as e:
logger.error(f"Password change error: {e}")
return False
def create_jwt_token(self, user_id: int, expires_hours: int = 24) -> Optional[str]:
"""Create JWT access token"""
try:
user = self.db_manager.get_user_by_id(user_id)
if not user:
return None
# Token payload
payload = {
'user_id': user.id,
'username': user.username,
'is_admin': user.is_admin,
'exp': datetime.utcnow() + timedelta(hours=expires_hours),
'iat': datetime.utcnow(),
'sub': str(user.id)
}
# Create token
token = jwt.encode(payload, self.jwt_secret, algorithm='HS256')
# Store token in database
api_token = APIToken(
user_id=user.id,
token_name=f"Web Token {datetime.utcnow().strftime('%Y-%m-%d %H:%M')}",
token_hash=self._hash_token(token),
expires_at=datetime.utcnow() + timedelta(hours=expires_hours),
created_at=datetime.utcnow()
)
self.db_manager.save_api_token(api_token)
logger.info(f"JWT token created for user: {user.username}")
return token
except Exception as e:
logger.error(f"JWT token creation error: {e}")
return None
def verify_jwt_token(self, token: str) -> Optional[Dict[str, Any]]:
"""Verify JWT token and return payload"""
try:
# Decode token
payload = jwt.decode(token, self.jwt_secret, algorithms=['HS256'])
# Check if token is in database and not revoked
token_hash = self._hash_token(token)
api_token = self.db_manager.get_api_token_by_hash(token_hash)
if not api_token or api_token.revoked:
logger.warning("JWT token verification failed: token revoked or not found")
return None
# Check expiration
if api_token.expires_at < datetime.utcnow():
logger.warning("JWT token verification failed: token expired")
return None
# Update last used
api_token.last_used = datetime.utcnow()
self.db_manager.save_api_token(api_token)
return payload
except jwt.ExpiredSignatureError:
logger.warning("JWT token verification failed: signature expired")
return None
except jwt.InvalidTokenError as e:
logger.warning(f"JWT token verification failed: invalid token - {e}")
return None
except Exception as e:
logger.error(f"JWT token verification error: {e}")
return None
def revoke_jwt_token(self, token: str) -> bool:
"""Revoke JWT token"""
try:
token_hash = self._hash_token(token)
api_token = self.db_manager.get_api_token_by_hash(token_hash)
if api_token:
api_token.revoked = True
api_token.updated_at = datetime.utcnow()
self.db_manager.save_api_token(api_token)
logger.info(f"JWT token revoked: {api_token.token_name}")
return True
return False
except Exception as e:
logger.error(f"JWT token revocation error: {e}")
return False
def create_api_token(self, user_id: int, token_name: str,
expires_hours: int = 8760) -> Optional[Tuple[str, APIToken]]: # 1 year default
"""Create long-lived API token"""
try:
user = self.db_manager.get_user_by_id(user_id)
if not user:
return None
# Generate secure token
token = secrets.token_urlsafe(32)
token_hash = self._hash_token(token)
# Create API token record
api_token = APIToken(
user_id=user.id,
token_name=token_name,
token_hash=token_hash,
expires_at=datetime.utcnow() + timedelta(hours=expires_hours),
created_at=datetime.utcnow()
)
saved_token = self.db_manager.save_api_token(api_token)
logger.info(f"API token created: {token_name} for user {user.username}")
return token, saved_token
except Exception as e:
logger.error(f"API token creation error: {e}")
return None
def verify_api_token(self, token: str) -> Optional[Dict[str, Any]]:
"""Verify API token"""
try:
token_hash = self._hash_token(token)
api_token = self.db_manager.get_api_token_by_hash(token_hash)
if not api_token or api_token.revoked:
logger.warning("API token verification failed: token revoked or not found")
return None
# Check expiration
if api_token.expires_at < datetime.utcnow():
logger.warning("API token verification failed: token expired")
return None
# Get user
user = self.db_manager.get_user_by_id(api_token.user_id)
if not user:
logger.warning("API token verification failed: user not found")
return None
# Update last used
api_token.last_used = datetime.utcnow()
self.db_manager.save_api_token(api_token)
return {
'user_id': user.id,
'username': user.username,
'is_admin': user.is_admin,
'token_name': api_token.token_name,
'token_id': api_token.id
}
except Exception as e:
logger.error(f"API token verification error: {e}")
return None
def list_user_tokens(self, user_id: int) -> list:
"""List all tokens for user"""
try:
tokens = self.db_manager.get_user_api_tokens(user_id)
return [
{
'id': token.id,
'name': token.token_name,
'created_at': token.created_at.isoformat(),
'expires_at': token.expires_at.isoformat(),
'last_used': token.last_used.isoformat() if token.last_used else None,
'revoked': token.revoked
}
for token in tokens
]
except Exception as e:
logger.error(f"Failed to list user tokens: {e}")
return []
def revoke_api_token_by_id(self, token_id: int, user_id: int) -> bool:
"""Revoke API token by ID"""
try:
api_token = self.db_manager.get_api_token_by_id(token_id)
if api_token and api_token.user_id == user_id:
api_token.revoked = True
api_token.updated_at = datetime.utcnow()
self.db_manager.save_api_token(api_token)
logger.info(f"API token revoked: {api_token.token_name}")
return True
return False
except Exception as e:
logger.error(f"API token revocation error: {e}")
return False
def cleanup_expired_tokens(self):
"""Clean up expired tokens"""
try:
count = self.db_manager.cleanup_expired_tokens()
if count > 0:
logger.info(f"Cleaned up {count} expired tokens")
except Exception as e:
logger.error(f"Token cleanup error: {e}")
def _hash_token(self, token: str) -> str:
"""Hash token for secure storage"""
return hashlib.sha256(token.encode()).hexdigest()
def require_auth(self, f):
"""Decorator for routes requiring authentication"""
from functools import wraps
@wraps(f)
def decorated_function(*args, **kwargs):
auth_header = request.headers.get('Authorization')
if auth_header and auth_header.startswith('Bearer '):
token = auth_header.split(' ', 1)[1]
# Try JWT token first
payload = self.verify_jwt_token(token)
if payload:
request.current_user = payload
return f(*args, **kwargs)
# Try API token
api_data = self.verify_api_token(token)
if api_data:
request.current_user = api_data
return f(*args, **kwargs)
return {'error': 'Authentication required'}, 401
return decorated_function
def require_admin(self, f):
"""Decorator for routes requiring admin access"""
from functools import wraps
@wraps(f)
def decorated_function(*args, **kwargs):
if not hasattr(request, 'current_user'):
return {'error': 'Authentication required'}, 401
if not request.current_user.get('is_admin', False):
return {'error': 'Admin access required'}, 403
return f(*args, **kwargs)
return decorated_function
\ No newline at end of file
"""
Flask routes for web dashboard
"""
import logging
from datetime import datetime
from flask import Blueprint, render_template, request, jsonify, redirect, url_for, flash, session
from flask_login import login_required, current_user, login_user, logout_user
from werkzeug.security import check_password_hash
from .auth import AuthenticatedUser
logger = logging.getLogger(__name__)
# Blueprint definitions
main_bp = Blueprint('main', __name__)
auth_bp = Blueprint('auth', __name__)
api_bp = Blueprint('api', __name__)
# These will be set by the app.py when registering blueprints
main_bp.db_manager = None
main_bp.config_manager = None
main_bp.message_bus = None
auth_bp.auth_manager = None
auth_bp.db_manager = None
api_bp.api = None
api_bp.db_manager = None
api_bp.config_manager = None
api_bp.message_bus = None
# Main routes
@main_bp.route('/')
@login_required
def index():
"""Dashboard home page"""
try:
return render_template('dashboard/index.html',
user=current_user,
page_title="Dashboard")
except Exception as e:
logger.error(f"Dashboard index error: {e}")
flash("Error loading dashboard", "error")
return render_template('errors/500.html'), 500
@main_bp.route('/video')
@login_required
def video_control():
"""Video control page"""
try:
return render_template('dashboard/video.html',
user=current_user,
page_title="Video Control")
except Exception as e:
logger.error(f"Video control page error: {e}")
flash("Error loading video control", "error")
return render_template('errors/500.html'), 500
@main_bp.route('/templates')
@login_required
def templates():
"""Template management page"""
try:
return render_template('dashboard/templates.html',
user=current_user,
page_title="Templates")
except Exception as e:
logger.error(f"Templates page error: {e}")
flash("Error loading templates", "error")
return render_template('errors/500.html'), 500
@main_bp.route('/config')
@login_required
def configuration():
"""Configuration page"""
try:
if not current_user.is_admin:
flash("Admin access required", "error")
return redirect(url_for('main.index'))
return render_template('dashboard/config.html',
user=current_user,
page_title="Configuration")
except Exception as e:
logger.error(f"Configuration page error: {e}")
flash("Error loading configuration", "error")
return render_template('errors/500.html'), 500
@main_bp.route('/users')
@login_required
def users():
"""User management page"""
try:
if not current_user.is_admin:
flash("Admin access required", "error")
return redirect(url_for('main.index'))
return render_template('dashboard/users.html',
user=current_user,
page_title="User Management")
except Exception as e:
logger.error(f"Users page error: {e}")
flash("Error loading user management", "error")
return render_template('errors/500.html'), 500
@main_bp.route('/tokens')
@login_required
def api_tokens():
"""API token management page"""
try:
return render_template('dashboard/tokens.html',
user=current_user,
page_title="API Tokens")
except Exception as e:
logger.error(f"API tokens page error: {e}")
flash("Error loading API tokens", "error")
return render_template('errors/500.html'), 500
@main_bp.route('/logs')
@login_required
def logs():
"""Application logs page"""
try:
if not current_user.is_admin:
flash("Admin access required", "error")
return redirect(url_for('main.index'))
return render_template('dashboard/logs.html',
user=current_user,
page_title="Application Logs")
except Exception as e:
logger.error(f"Logs page error: {e}")
flash("Error loading logs", "error")
return render_template('errors/500.html'), 500
# Auth routes
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
"""Login page"""
if current_user.is_authenticated:
return redirect(url_for('main.index'))
if request.method == 'POST':
try:
username = request.form.get('username', '').strip()
password = request.form.get('password', '')
remember_me = request.form.get('remember_me', False)
if not username or not password:
flash("Username and password are required", "error")
return render_template('auth/login.html')
# Authenticate user
authenticated_user = auth_bp.auth_manager.authenticate_user(username, password)
if authenticated_user:
login_user(authenticated_user, remember=remember_me)
logger.info(f"User logged in: {username}")
# Redirect to next page or dashboard
next_page = request.args.get('next')
if next_page:
return redirect(next_page)
else:
return redirect(url_for('main.index'))
else:
flash("Invalid username or password", "error")
return render_template('auth/login.html')
except Exception as e:
logger.error(f"Login error: {e}")
flash("Login failed. Please try again.", "error")
return render_template('auth/login.html')
return render_template('auth/login.html')
@auth_bp.route('/logout')
@login_required
def logout():
"""Logout user"""
try:
username = current_user.username
logout_user()
logger.info(f"User logged out: {username}")
flash("You have been logged out", "info")
except Exception as e:
logger.error(f"Logout error: {e}")
return redirect(url_for('auth.login'))
@auth_bp.route('/change-password', methods=['GET', 'POST'])
@login_required
def change_password():
"""Change password page"""
if request.method == 'POST':
try:
current_password = request.form.get('current_password', '')
new_password = request.form.get('new_password', '')
confirm_password = request.form.get('confirm_password', '')
if not all([current_password, new_password, confirm_password]):
flash("All fields are required", "error")
return render_template('auth/change_password.html')
if new_password != confirm_password:
flash("New passwords do not match", "error")
return render_template('auth/change_password.html')
if len(new_password) < 6:
flash("Password must be at least 6 characters", "error")
return render_template('auth/change_password.html')
# Change password
success = auth_bp.auth_manager.change_password(
current_user.id, current_password, new_password
)
if success:
flash("Password changed successfully", "success")
return redirect(url_for('main.index'))
else:
flash("Current password is incorrect", "error")
return render_template('auth/change_password.html')
except Exception as e:
logger.error(f"Change password error: {e}")
flash("Failed to change password", "error")
return render_template('auth/change_password.html')
return render_template('auth/change_password.html')
# API routes
@api_bp.route('/status')
def system_status():
"""Get system status"""
try:
status = api_bp.api.get_system_status()
return jsonify(status)
except Exception as e:
logger.error(f"API status error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/video/status')
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def video_status():
"""Get video player status"""
try:
status = api_bp.api.get_video_status()
return jsonify(status)
except Exception as e:
logger.error(f"API video status error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/video/control', methods=['POST'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def video_control():
"""Control video player"""
try:
data = request.get_json() or {}
action = data.get('action')
if not action:
return jsonify({"error": "Action is required"}), 400
result = api_bp.api.control_video(action, **data)
return jsonify(result)
except Exception as e:
logger.error(f"API video control error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/overlay', methods=['POST'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def update_overlay():
"""Update video overlay"""
try:
data = request.get_json() or {}
template = data.get('template')
overlay_data = data.get('data', {})
if not template:
return jsonify({"error": "Template is required"}), 400
result = api_bp.api.update_overlay(template, overlay_data)
return jsonify(result)
except Exception as e:
logger.error(f"API overlay update error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/templates')
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def get_templates():
"""Get available templates"""
try:
templates = api_bp.api.get_templates()
return jsonify(templates)
except Exception as e:
logger.error(f"API get templates error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/config')
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def get_configuration():
"""Get configuration"""
try:
section = request.args.get('section')
config = api_bp.api.get_configuration(section)
return jsonify(config)
except Exception as e:
logger.error(f"API get configuration error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/config', methods=['POST'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
@api_bp.auth_manager.require_admin if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def update_configuration():
"""Update configuration"""
try:
data = request.get_json() or {}
section = data.get('section')
config_data = data.get('config')
if not section or not config_data:
return jsonify({"error": "Section and config data are required"}), 400
result = api_bp.api.update_configuration(section, config_data)
return jsonify(result)
except Exception as e:
logger.error(f"API update configuration error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/users')
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
@api_bp.auth_manager.require_admin if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def get_users():
"""Get all users"""
try:
users = api_bp.api.get_users()
return jsonify(users)
except Exception as e:
logger.error(f"API get users error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/users', methods=['POST'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
@api_bp.auth_manager.require_admin if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def create_user():
"""Create new user"""
try:
data = request.get_json() or {}
username = data.get('username', '').strip()
email = data.get('email', '').strip()
password = data.get('password', '')
is_admin = data.get('is_admin', False)
if not all([username, email, password]):
return jsonify({"error": "Username, email, and password are required"}), 400
result = api_bp.api.create_user(username, email, password, is_admin)
return jsonify(result)
except Exception as e:
logger.error(f"API create user error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/users/<int:user_id>', methods=['DELETE'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
@api_bp.auth_manager.require_admin if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def delete_user(user_id):
"""Delete user"""
try:
result = api_bp.api.delete_user(user_id)
return jsonify(result)
except Exception as e:
logger.error(f"API delete user error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/tokens')
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def get_api_tokens():
"""Get API tokens for current user"""
try:
user_id = getattr(current_user, 'id', None) or getattr(request, 'current_user', {}).get('user_id')
if not user_id:
return jsonify({"error": "User not authenticated"}), 401
tokens = api_bp.api.get_api_tokens(user_id)
return jsonify(tokens)
except Exception as e:
logger.error(f"API get tokens error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/tokens', methods=['POST'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def create_api_token():
"""Create API token"""
try:
data = request.get_json() or {}
token_name = data.get('name', '').strip()
expires_hours = data.get('expires_hours', 8760) # 1 year default
if not token_name:
return jsonify({"error": "Token name is required"}), 400
user_id = getattr(current_user, 'id', None) or getattr(request, 'current_user', {}).get('user_id')
if not user_id:
return jsonify({"error": "User not authenticated"}), 401
result = api_bp.api.create_api_token(user_id, token_name, expires_hours)
return jsonify(result)
except Exception as e:
logger.error(f"API create token error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/tokens/<int:token_id>', methods=['DELETE'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def revoke_api_token(token_id):
"""Revoke API token"""
try:
user_id = getattr(current_user, 'id', None) or getattr(request, 'current_user', {}).get('user_id')
if not user_id:
return jsonify({"error": "User not authenticated"}), 401
result = api_bp.api.revoke_api_token(user_id, token_id)
return jsonify(result)
except Exception as e:
logger.error(f"API revoke token error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/logs')
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
@api_bp.auth_manager.require_admin if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def get_logs():
"""Get application logs"""
try:
level = request.args.get('level', 'INFO')
limit = int(request.args.get('limit', 100))
logs = api_bp.api.get_logs(level, limit)
return jsonify(logs)
except Exception as e:
logger.error(f"API get logs error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/test-message', methods=['POST'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
@api_bp.auth_manager.require_admin if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def send_test_message():
"""Send test message to component"""
try:
data = request.get_json() or {}
recipient = data.get('recipient')
message_type = data.get('message_type')
message_data = data.get('data', {})
if not recipient or not message_type:
return jsonify({"error": "Recipient and message type are required"}), 400
result = api_bp.api.send_test_message(recipient, message_type, message_data)
return jsonify(result)
except Exception as e:
logger.error(f"API test message error: {e}")
return jsonify({"error": str(e)}), 500
# Auth token endpoint for JWT creation
@auth_bp.route('/token', methods=['POST'])
def create_auth_token():
"""Create JWT authentication token"""
try:
data = request.get_json() or {}
username = data.get('username', '').strip()
password = data.get('password', '')
if not username or not password:
return jsonify({"error": "Username and password are required"}), 400
# Authenticate user
authenticated_user = auth_bp.auth_manager.authenticate_user(username, password)
if authenticated_user:
# Create JWT token
token = auth_bp.auth_manager.create_jwt_token(authenticated_user.id)
if token:
return jsonify({
"access_token": token,
"token_type": "bearer",
"user": authenticated_user.to_dict()
})
else:
return jsonify({"error": "Failed to create token"}), 500
else:
return jsonify({"error": "Invalid credentials"}), 401
except Exception as e:
logger.error(f"Token creation error: {e}")
return jsonify({"error": str(e)}), 500
\ No newline at end of file
/* MbetterClient Dashboard CSS - Offline-first design */
:root {
--primary-color: #0d6efd;
--success-color: #198754;
--info-color: #0dcaf0;
--warning-color: #ffc107;
--danger-color: #dc3545;
--dark-color: #212529;
--light-color: #f8f9fa;
}
/* Global Styles */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
}
.text-small {
font-size: 0.875rem;
}
/* Navbar customizations */
.navbar-brand {
font-weight: 600;
font-size: 1.25rem;
}
.navbar-nav .nav-link {
font-weight: 500;
transition: all 0.3s ease;
}
.navbar-nav .nav-link:hover {
transform: translateY(-1px);
}
.navbar-nav .nav-link.active {
background-color: rgba(255, 255, 255, 0.1);
border-radius: 0.375rem;
}
/* Status cards */
.card {
border: none;
border-radius: 0.75rem;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
transition: all 0.3s ease;
}
.card:hover {
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.card-header {
background-color: transparent;
border-bottom: 1px solid rgba(0, 0, 0, 0.125);
font-weight: 600;
}
/* Status badges */
.badge {
font-size: 0.75rem;
font-weight: 600;
}
/* Buttons */
.btn {
border-radius: 0.5rem;
font-weight: 500;
transition: all 0.3s ease;
}
.btn:hover {
transform: translateY(-1px);
}
.btn-outline-primary:hover,
.btn-outline-success:hover,
.btn-outline-info:hover,
.btn-outline-warning:hover {
transform: translateY(-2px);
}
/* Forms */
.form-control, .form-select {
border-radius: 0.5rem;
border: 2px solid #e9ecef;
transition: all 0.3s ease;
}
.form-control:focus, .form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
}
.input-group-text {
border-radius: 0.5rem 0 0 0.5rem;
border: 2px solid #e9ecef;
border-right: none;
background-color: #f8f9fa;
}
/* Tables */
.table {
border-collapse: separate;
border-spacing: 0;
}
.table th {
border-top: none;
border-bottom: 2px solid #dee2e6;
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
}
.table td {
border-top: 1px solid #dee2e6;
vertical-align: middle;
}
.table-striped > tbody > tr:nth-of-type(odd) > td {
background-color: rgba(0, 0, 0, 0.02);
}
/* Login page specific styles */
.min-vh-100 {
min-height: 100vh;
}
.login-card {
border: none;
border-radius: 1rem;
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.175);
}
/* Status bar */
#status-bar {
z-index: 1000;
border-top: 1px solid #dee2e6;
background-color: rgba(248, 249, 250, 0.95);
backdrop-filter: blur(10px);
}
/* Offline indicator */
#offline-indicator {
z-index: 1050;
}
/* Activity list */
#activity-list .flex-shrink-0 i {
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.1);
}
/* Video control page */
.video-controls {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 1rem;
color: white;
}
.video-controls .btn {
border-color: rgba(255, 255, 255, 0.5);
color: white;
}
.video-controls .btn:hover {
background-color: rgba(255, 255, 255, 0.2);
border-color: white;
}
/* Template management */
.template-card {
cursor: pointer;
transition: all 0.3s ease;
}
.template-card:hover {
transform: scale(1.02);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
.template-preview {
height: 120px;
background: linear-gradient(45deg, #f8f9fa, #e9ecef);
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
color: #6c757d;
}
/* API tokens page */
.token-item {
border-left: 4px solid var(--primary-color);
background-color: #f8f9fa;
}
.token-expired {
border-left-color: var(--danger-color);
background-color: #fdf2f2;
}
/* Configuration page */
.config-section {
border-left: 3px solid var(--primary-color);
padding-left: 1rem;
}
/* Logs page */
.log-entry {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.875rem;
line-height: 1.4;
}
.log-error {
color: var(--danger-color);
background-color: rgba(220, 53, 69, 0.1);
}
.log-warning {
color: #856404;
background-color: rgba(255, 193, 7, 0.1);
}
.log-info {
color: var(--info-color);
}
.log-debug {
color: #6c757d;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.navbar-nav .dropdown-menu {
border: none;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
#status-bar {
display: none !important;
}
.card {
margin-bottom: 1rem;
}
.table-responsive {
border-radius: 0.5rem;
}
}
/* Dark mode support (future enhancement) */
@media (prefers-color-scheme: dark) {
:root {
--bs-body-bg: #1a1a1a;
--bs-body-color: #f8f9fa;
}
}
/* Loading states */
.loading {
opacity: 0.6;
pointer-events: none;
position: relative;
}
.loading::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 1rem;
height: 1rem;
margin: -0.5rem 0 0 -0.5rem;
border: 2px solid transparent;
border-top: 2px solid var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Success/Error states */
.success-flash {
animation: successFlash 0.5s ease-in-out;
}
@keyframes successFlash {
0% { background-color: rgba(25, 135, 84, 0.1); }
100% { background-color: transparent; }
}
.error-flash {
animation: errorFlash 0.5s ease-in-out;
}
@keyframes errorFlash {
0% { background-color: rgba(220, 53, 69, 0.1); }
100% { background-color: transparent; }
}
/* Accessibility improvements */
.btn:focus,
.form-control:focus,
.form-select:focus {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Print styles */
@media print {
.navbar,
#status-bar,
.btn,
.modal {
display: none !important;
}
.card {
box-shadow: none;
border: 1px solid #dee2e6;
}
.table {
border-collapse: collapse;
}
}
/* Utilities */
.opacity-75 {
opacity: 0.75;
}
.fw-bold {
font-weight: 600 !important;
}
.text-decoration-none {
text-decoration: none !important;
}
.cursor-pointer {
cursor: pointer;
}
.user-select-none {
user-select: none;
}
\ No newline at end of file
/**
* MbetterClient Dashboard JavaScript - Offline-first functionality
*/
// Dashboard namespace
window.Dashboard = (function() {
'use strict';
let config = {};
let statusInterval = null;
let isOnline = navigator.onLine;
let cache = {};
// Initialize dashboard
function init(userConfig) {
config = Object.assign({
statusUpdateInterval: 30000,
apiEndpoint: '/api',
user: null
}, userConfig);
console.log('Dashboard initializing...', config);
// Setup event listeners
setupEventListeners();
// Start status updates
startStatusUpdates();
// Setup offline/online detection
setupOfflineDetection();
// Load cached data
loadFromCache();
console.log('Dashboard initialized successfully');
}
// Setup event listeners
function setupEventListeners() {
// Generic API form handling
document.addEventListener('submit', function(e) {
if (e.target.classList.contains('api-form')) {
e.preventDefault();
handleApiForm(e.target);
}
});
// Generic API button handling
document.addEventListener('click', function(e) {
if (e.target.classList.contains('api-btn')) {
e.preventDefault();
handleApiButton(e.target);
}
});
// Toast notifications
setupToastNotifications();
}
// Setup offline/online detection
function setupOfflineDetection() {
window.addEventListener('online', function() {
isOnline = true;
showNotification('Connection restored', 'success');
updateOnlineStatus();
// Sync cached changes when coming back online
syncCachedChanges();
});
window.addEventListener('offline', function() {
isOnline = false;
showNotification('You are now offline', 'warning');
updateOnlineStatus();
});
updateOnlineStatus();
}
// Update online status indicator
function updateOnlineStatus() {
const indicators = document.querySelectorAll('.online-status');
indicators.forEach(function(indicator) {
indicator.textContent = isOnline ? 'Online' : 'Offline';
indicator.className = 'online-status badge ' + (isOnline ? 'bg-success' : 'bg-warning');
});
}
// Start periodic status updates
function startStatusUpdates() {
if (statusInterval) {
clearInterval(statusInterval);
}
// Initial update
updateSystemStatus();
// Periodic updates
statusInterval = setInterval(function() {
if (isOnline) {
updateSystemStatus();
}
}, config.statusUpdateInterval);
}
// Update system status
function updateSystemStatus() {
apiRequest('GET', '/status')
.then(function(data) {
updateStatusDisplay(data);
cacheData('system_status', data);
})
.catch(function(error) {
console.error('Failed to update system status:', error);
// Use cached data if available
const cached = getFromCache('system_status');
if (cached) {
updateStatusDisplay(cached);
}
});
}
// Update status display elements
function updateStatusDisplay(data) {
// Update system status
const systemStatus = document.getElementById('system-status');
if (systemStatus && data.status) {
systemStatus.textContent = data.status.charAt(0).toUpperCase() + data.status.slice(1);
systemStatus.className = 'badge ' + (data.status === 'online' ? 'bg-success' : 'bg-danger');
}
// Update last updated time
const lastUpdated = document.getElementById('last-updated');
if (lastUpdated) {
lastUpdated.textContent = new Date().toLocaleTimeString();
}
// Update component status
if (data.components) {
Object.keys(data.components).forEach(function(component) {
const badge = document.getElementById(component + '-badge');
if (badge) {
const status = data.components[component];
badge.className = 'badge me-1 ' + (status === 'running' ? 'bg-success' : 'bg-secondary');
}
});
}
}
// Generic API request handler
function apiRequest(method, endpoint, data) {
const url = config.apiEndpoint + endpoint;
const options = {
method: method,
headers: {
'Content-Type': 'application/json',
}
};
if (data) {
options.body = JSON.stringify(data);
}
return fetch(url, options)
.then(function(response) {
if (!response.ok) {
throw new Error('API request failed: ' + response.status);
}
return response.json();
});
}
// Handle API form submissions
function handleApiForm(form) {
const formData = new FormData(form);
const data = {};
formData.forEach(function(value, key) {
data[key] = value;
});
const method = form.dataset.method || 'POST';
const endpoint = form.dataset.endpoint;
if (!endpoint) {
console.error('Form missing data-endpoint attribute');
return;
}
showLoading(form);
apiRequest(method, endpoint, data)
.then(function(response) {
hideLoading(form);
if (response.success) {
showNotification(response.message || 'Operation completed successfully', 'success');
form.reset();
// Refresh data if needed
const refreshTarget = form.dataset.refresh;
if (refreshTarget) {
refreshSection(refreshTarget);
}
} else {
showNotification(response.error || 'Operation failed', 'error');
}
})
.catch(function(error) {
hideLoading(form);
showNotification('Error: ' + error.message, 'error');
// Cache the operation for later if offline
if (!isOnline) {
cacheOperation(method, endpoint, data);
showNotification('Operation cached for when connection is restored', 'info');
}
});
}
// Handle API button clicks
function handleApiButton(button) {
const method = button.dataset.method || 'POST';
const endpoint = button.dataset.endpoint;
const data = JSON.parse(button.dataset.data || '{}');
if (!endpoint) {
console.error('Button missing data-endpoint attribute');
return;
}
// Confirmation if requested
if (button.dataset.confirm) {
if (!confirm(button.dataset.confirm)) {
return;
}
}
showLoading(button);
apiRequest(method, endpoint, data)
.then(function(response) {
hideLoading(button);
if (response.success) {
showNotification(response.message || 'Operation completed successfully', 'success');
// Refresh data if needed
const refreshTarget = button.dataset.refresh;
if (refreshTarget) {
refreshSection(refreshTarget);
}
} else {
showNotification(response.error || 'Operation failed', 'error');
}
})
.catch(function(error) {
hideLoading(button);
showNotification('Error: ' + error.message, 'error');
// Cache the operation for later if offline
if (!isOnline) {
cacheOperation(method, endpoint, data);
showNotification('Operation cached for when connection is restored', 'info');
}
});
}
// Show loading state
function showLoading(element) {
element.classList.add('loading');
element.disabled = true;
}
// Hide loading state
function hideLoading(element) {
element.classList.remove('loading');
element.disabled = false;
}
// Refresh a section
function refreshSection(sectionId) {
const section = document.getElementById(sectionId);
if (!section) return;
// Add refresh logic here based on section type
console.log('Refreshing section:', sectionId);
}
// Cache management
function cacheData(key, data) {
try {
cache[key] = {
data: data,
timestamp: Date.now()
};
localStorage.setItem('dashboard_cache', JSON.stringify(cache));
} catch (e) {
console.warn('Failed to cache data:', e);
}
}
function getFromCache(key) {
try {
const cached = cache[key];
if (cached && (Date.now() - cached.timestamp) < 300000) { // 5 minutes
return cached.data;
}
} catch (e) {
console.warn('Failed to get cached data:', e);
}
return null;
}
function loadFromCache() {
try {
const cached = localStorage.getItem('dashboard_cache');
if (cached) {
cache = JSON.parse(cached);
}
} catch (e) {
console.warn('Failed to load cache:', e);
cache = {};
}
}
// Cache operations for offline sync
function cacheOperation(method, endpoint, data) {
try {
let operations = JSON.parse(localStorage.getItem('cached_operations') || '[]');
operations.push({
method: method,
endpoint: endpoint,
data: data,
timestamp: Date.now()
});
localStorage.setItem('cached_operations', JSON.stringify(operations));
} catch (e) {
console.warn('Failed to cache operation:', e);
}
}
// Sync cached operations when coming back online
function syncCachedChanges() {
try {
const operations = JSON.parse(localStorage.getItem('cached_operations') || '[]');
if (operations.length === 0) return;
console.log('Syncing', operations.length, 'cached operations');
operations.forEach(function(operation, index) {
apiRequest(operation.method, operation.endpoint, operation.data)
.then(function(response) {
console.log('Synced operation', index + 1, '/', operations.length);
})
.catch(function(error) {
console.error('Failed to sync operation:', error);
});
});
// Clear cached operations
localStorage.removeItem('cached_operations');
showNotification('Cached changes synchronized', 'success');
} catch (e) {
console.warn('Failed to sync cached operations:', e);
}
}
// Toast notifications
function setupToastNotifications() {
// Create toast container if it doesn't exist
if (!document.getElementById('toast-container')) {
const container = document.createElement('div');
container.id = 'toast-container';
container.className = 'toast-container position-fixed bottom-0 end-0 p-3';
container.style.zIndex = '1060';
document.body.appendChild(container);
}
}
function showNotification(message, type) {
const container = document.getElementById('toast-container');
if (!container) return;
const toastId = 'toast-' + Date.now();
const iconClass = {
'success': 'fas fa-check-circle text-success',
'error': 'fas fa-exclamation-triangle text-danger',
'warning': 'fas fa-exclamation-triangle text-warning',
'info': 'fas fa-info-circle text-info'
}[type] || 'fas fa-info-circle text-info';
const toast = document.createElement('div');
toast.id = toastId;
toast.className = 'toast';
toast.setAttribute('role', 'alert');
toast.innerHTML = `
<div class="toast-header">
<i class="${iconClass} me-2"></i>
<strong class="me-auto">MbetterClient</strong>
<small class="text-muted">now</small>
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body">
${message}
</div>
`;
container.appendChild(toast);
// Initialize and show toast
if (typeof bootstrap !== 'undefined') {
const bsToast = new bootstrap.Toast(toast, { delay: 5000 });
bsToast.show();
// Remove from DOM after hiding
toast.addEventListener('hidden.bs.toast', function() {
toast.remove();
});
} else {
// Fallback without Bootstrap
setTimeout(function() {
toast.remove();
}, 5000);
}
}
// Video control helpers
function updateVideoStatus() {
apiRequest('GET', '/video/status')
.then(function(data) {
const statusElement = document.getElementById('video-status');
if (statusElement && data.player_status) {
statusElement.textContent = data.player_status.charAt(0).toUpperCase() + data.player_status.slice(1);
statusElement.className = 'badge ' + (data.player_status === 'playing' ? 'bg-success' : 'bg-secondary');
}
cacheData('video_status', data);
})
.catch(function(error) {
console.error('Failed to update video status:', error);
const cached = getFromCache('video_status');
if (cached && document.getElementById('video-status')) {
document.getElementById('video-status').textContent = cached.player_status || 'Unknown';
}
});
}
// Utility functions
function formatBytes(bytes, decimals) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
function formatTimestamp(timestamp) {
return new Date(timestamp).toLocaleString();
}
// Public API
return {
init: init,
apiRequest: apiRequest,
showNotification: showNotification,
updateVideoStatus: updateVideoStatus,
updateSystemStatus: updateSystemStatus,
formatBytes: formatBytes,
formatTimestamp: formatTimestamp,
isOnline: function() { return isOnline; },
getConfig: function() { return config; }
};
})();
// Auto-initialize if config is available
document.addEventListener('DOMContentLoaded', function() {
const configScript = document.getElementById('dashboard-config');
if (configScript && typeof Dashboard !== 'undefined') {
try {
const config = JSON.parse(configScript.textContent);
Dashboard.init(config);
} catch (e) {
console.error('Failed to parse dashboard config:', e);
}
}
});
\ No newline at end of file
{% extends "base.html" %}
{% block title %}Login - {{ app_name }}{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center min-vh-100 align-items-center">
<div class="col-md-6 col-lg-4">
<div class="card shadow">
<div class="card-body p-5">
<div class="text-center mb-4">
<i class="fas fa-play-circle fa-3x text-primary mb-3"></i>
<h2 class="card-title">{{ app_name }}</h2>
<p class="text-muted">Sign in to your account</p>
</div>
<form method="POST" action="{{ url_for('auth.login') }}">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-user"></i>
</span>
<input type="text" class="form-control" id="username" name="username"
placeholder="Enter your username" required autofocus>
</div>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-lock"></i>
</span>
<input type="password" class="form-control" id="password" name="password"
placeholder="Enter your password" required>
</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="remember_me" name="remember_me">
<label class="form-check-label" for="remember_me">
Remember me
</label>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">
<i class="fas fa-sign-in-alt me-2"></i>Sign In
</button>
</div>
</form>
</div>
</div>
<div class="text-center mt-4">
<small class="text-muted">
{{ app_name }} v{{ app_version }}
</small>
</div>
</div>
</div>
</div>
<!-- Offline status indicator -->
<div id="offline-indicator" class="position-fixed bottom-0 start-0 m-3 d-none">
<div class="alert alert-warning mb-0">
<i class="fas fa-wifi me-2"></i>
<small>You are currently offline</small>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Handle offline/online status
function updateOnlineStatus() {
const indicator = document.getElementById('offline-indicator');
if (navigator.onLine) {
indicator.classList.add('d-none');
} else {
indicator.classList.remove('d-none');
}
}
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
// Initial check
updateOnlineStatus();
// Focus username field on page load
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('username').focus();
});
</script>
{% endblock %}
\ No newline at end of file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ page_title | default("Dashboard") }} - {{ app_name }}{% endblock %}</title>
<!-- Offline-first CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/dashboard.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/fontawesome.min.css') }}">
<!-- CDN fallbacks -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
onerror="this.remove()">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
onerror="this.remove()">
{% block head %}{% endblock %}
</head>
<body>
{% if current_user.is_authenticated %}
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('main.index') }}">
<i class="fas fa-play-circle me-2"></i>{{ app_name }}
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.index' %}active{% endif %}"
href="{{ url_for('main.index') }}">
<i class="fas fa-tachometer-alt me-1"></i>Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.video_control' %}active{% endif %}"
href="{{ url_for('main.video_control') }}">
<i class="fas fa-video me-1"></i>Video Control
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.templates' %}active{% endif %}"
href="{{ url_for('main.templates') }}">
<i class="fas fa-layer-group me-1"></i>Templates
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.api_tokens' %}active{% endif %}"
href="{{ url_for('main.api_tokens') }}">
<i class="fas fa-key me-1"></i>API Tokens
</a>
</li>
{% if current_user.is_admin %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="fas fa-cog me-1"></i>Admin
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('main.configuration') }}">
<i class="fas fa-sliders-h me-1"></i>Configuration
</a></li>
<li><a class="dropdown-item" href="{{ url_for('main.users') }}">
<i class="fas fa-users me-1"></i>User Management
</a></li>
<li><a class="dropdown-item" href="{{ url_for('main.logs') }}">
<i class="fas fa-file-alt me-1"></i>Logs
</a></li>
</ul>
</li>
{% endif %}
</ul>
<ul class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="fas fa-user me-1"></i>{{ current_user.username }}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{{ url_for('auth.change_password') }}">
<i class="fas fa-lock me-1"></i>Change Password
</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">
<i class="fas fa-sign-out-alt me-1"></i>Logout
</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
{% endif %}
<main class="{% if current_user.is_authenticated %}container-fluid mt-4{% else %}container{% endif %}">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{% if category == 'error' %}
<i class="fas fa-exclamation-triangle me-2"></i>
{% elif category == 'success' %}
<i class="fas fa-check-circle me-2"></i>
{% elif category == 'info' %}
<i class="fas fa-info-circle me-2"></i>
{% endif %}
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
{% if current_user.is_authenticated %}
<!-- System Status Bar -->
<div id="status-bar" class="fixed-bottom bg-light border-top p-2 d-none d-lg-block">
<div class="container-fluid">
<div class="row align-items-center text-small">
<div class="col-auto">
<span class="text-muted">Status:</span>
<span id="system-status" class="badge bg-success">Online</span>
</div>
<div class="col-auto">
<span class="text-muted">Video:</span>
<span id="video-status" class="badge bg-secondary">Stopped</span>
</div>
<div class="col-auto">
<span class="text-muted">Last Updated:</span>
<span id="last-updated" class="text-muted">--</span>
</div>
<div class="col text-end">
<small class="text-muted">{{ app_name }} v{{ app_version }}</small>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Offline-first JavaScript -->
<script src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
<!-- CDN fallbacks -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"
onerror="this.remove()"></script>
{% if current_user.is_authenticated %}
<script id="dashboard-config" type="application/json">
{
"statusUpdateInterval": 30000,
"apiEndpoint": "/api",
"user": {
"id": {{ current_user.id | tojson }},
"username": {{ current_user.username | tojson }},
"is_admin": {{ current_user.is_admin | tojson }}
}
}
</script>
<script>
// Initialize dashboard
document.addEventListener('DOMContentLoaded', function() {
if (typeof Dashboard !== 'undefined') {
var config = JSON.parse(document.getElementById('dashboard-config').textContent);
Dashboard.init(config);
}
});
</script>
{% endif %}
{% block scripts %}{% endblock %}
</body>
</html>
\ No newline at end of file
{% extends "base.html" %}
{% block content %}
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="fas fa-tachometer-alt me-2"></i>Dashboard
<small class="text-muted">Welcome, {{ current_user.username }}</small>
</h1>
</div>
</div>
<!-- System Status Cards -->
<div class="row mb-4">
<div class="col-md-3 col-sm-6">
<div class="card bg-primary text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-title">System Status</h6>
<h4 id="system-status-text">Online</h4>
</div>
<div class="align-self-center">
<i class="fas fa-server fa-2x opacity-75"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="card bg-success text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-title">Video Player</h6>
<h4 id="video-status-text">Ready</h4>
</div>
<div class="align-self-center">
<i class="fas fa-video fa-2x opacity-75"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="card bg-info text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-title">Active Template</h6>
<h4 id="active-template">News</h4>
</div>
<div class="align-self-center">
<i class="fas fa-layer-group fa-2x opacity-75"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="card bg-warning text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-title">API Tokens</h6>
<h4 id="token-count">0</h4>
</div>
<div class="align-self-center">
<i class="fas fa-key fa-2x opacity-75"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-bolt me-2"></i>Quick Actions
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4 mb-3">
<button class="btn btn-outline-primary w-100" id="btn-play-video">
<i class="fas fa-play me-2"></i>Start Video Playback
</button>
</div>
<div class="col-md-4 mb-3">
<button class="btn btn-outline-success w-100" id="btn-update-overlay">
<i class="fas fa-edit me-2"></i>Update Overlay
</button>
</div>
<div class="col-md-4 mb-3">
<button class="btn btn-outline-info w-100" id="btn-create-token">
<i class="fas fa-plus me-2"></i>Create API Token
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Activity & System Info -->
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">
<i class="fas fa-clock me-2"></i>Recent Activity
</h5>
<small class="text-muted">Last 24 hours</small>
</div>
<div class="card-body">
<div id="activity-list">
<div class="d-flex align-items-center mb-3">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-info"></i>
</div>
<div class="flex-grow-1 ms-3">
<div class="fw-bold">System Started</div>
<small class="text-muted">Web dashboard initialized successfully</small>
</div>
<div class="flex-shrink-0">
<small class="text-muted">Just now</small>
</div>
</div>
<div class="d-flex align-items-center mb-3">
<div class="flex-shrink-0">
<i class="fas fa-user text-success"></i>
</div>
<div class="flex-grow-1 ms-3">
<div class="fw-bold">User Login</div>
<small class="text-muted">{{ current_user.username }} logged in</small>
</div>
<div class="flex-shrink-0">
<small class="text-muted">Just now</small>
</div>
</div>
<div class="text-center text-muted mt-4">
<small>More activity will appear here as you use the system</small>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-info-circle me-2"></i>System Information
</h5>
</div>
<div class="card-body">
<dl class="mb-0">
<dt class="text-muted">Version</dt>
<dd>{{ app_version }}</dd>
<dt class="text-muted">Uptime</dt>
<dd id="system-uptime">Just started</dd>
<dt class="text-muted">User Role</dt>
<dd>
{% if current_user.is_admin %}
<span class="badge bg-danger">Administrator</span>
{% else %}
<span class="badge bg-primary">User</span>
{% endif %}
</dd>
<dt class="text-muted">Components</dt>
<dd>
<span class="badge bg-success me-1">Web</span>
<span class="badge bg-secondary me-1" id="player-badge">Player</span>
<span class="badge bg-secondary" id="api-badge">API Client</span>
</dd>
</dl>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-chart-line me-2"></i>Quick Stats
</h5>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6">
<h4 class="text-primary" id="videos-played">0</h4>
<small class="text-muted">Videos Played</small>
</div>
<div class="col-6">
<h4 class="text-success" id="overlays-updated">0</h4>
<small class="text-muted">Overlays Updated</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Action Modals -->
<div class="modal fade" id="playVideoModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Start Video Playback</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="play-video-form">
<div class="mb-3">
<label class="form-label">Video File Path</label>
<input type="text" class="form-control" id="video-file-path"
placeholder="/path/to/video.mp4">
</div>
<div class="mb-3">
<label class="form-label">Template</label>
<select class="form-select" id="video-template">
<option value="news_template">News Template</option>
<option value="sports_template">Sports Template</option>
<option value="simple_template">Simple Template</option>
</select>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="confirm-play-video">
<i class="fas fa-play me-1"></i>Start Playback
</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="updateOverlayModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Update Overlay</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="update-overlay-form">
<div class="mb-3">
<label class="form-label">Template</label>
<select class="form-select" id="overlay-template">
<option value="news_template">News Template</option>
<option value="sports_template">Sports Template</option>
<option value="simple_template">Simple Template</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Headline/Title</label>
<input type="text" class="form-control" id="overlay-headline"
placeholder="Breaking News">
</div>
<div class="mb-3">
<label class="form-label">Text Content</label>
<textarea class="form-control" id="overlay-text" rows="3"
placeholder="Additional information..."></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" id="confirm-update-overlay">
<i class="fas fa-edit me-1"></i>Update Overlay
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Quick action buttons
document.getElementById('btn-play-video').addEventListener('click', function() {
new bootstrap.Modal(document.getElementById('playVideoModal')).show();
});
document.getElementById('btn-update-overlay').addEventListener('click', function() {
new bootstrap.Modal(document.getElementById('updateOverlayModal')).show();
});
document.getElementById('btn-create-token').addEventListener('click', function() {
window.location.href = '/tokens';
});
// Confirm actions
document.getElementById('confirm-play-video').addEventListener('click', function() {
const filePath = document.getElementById('video-file-path').value;
const template = document.getElementById('video-template').value;
if (!filePath) {
alert('Please enter a video file path');
return;
}
fetch('/api/video/control', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'play',
file_path: filePath,
template: template
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('playVideoModal')).hide();
updateVideoStatus();
} else {
alert('Failed to start video: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
alert('Error: ' + error.message);
});
});
document.getElementById('confirm-update-overlay').addEventListener('click', function() {
const template = document.getElementById('overlay-template').value;
const headline = document.getElementById('overlay-headline').value;
const text = document.getElementById('overlay-text').value;
fetch('/api/overlay', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
template: template,
data: {
headline: headline,
ticker_text: text,
title: headline,
text: text
}
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('updateOverlayModal')).hide();
document.getElementById('overlays-updated').textContent =
parseInt(document.getElementById('overlays-updated').textContent) + 1;
} else {
alert('Failed to update overlay: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
alert('Error: ' + error.message);
});
});
// Status update functions
function updateSystemStatus() {
fetch('/api/status')
.then(response => response.json())
.then(data => {
if (data.status === 'online') {
document.getElementById('system-status-text').textContent = 'Online';
document.getElementById('system-status').className = 'badge bg-success';
} else {
document.getElementById('system-status-text').textContent = 'Error';
document.getElementById('system-status').className = 'badge bg-danger';
}
})
.catch(error => {
document.getElementById('system-status-text').textContent = 'Offline';
document.getElementById('system-status').className = 'badge bg-danger';
});
}
function updateVideoStatus() {
fetch('/api/video/status')
.then(response => response.json())
.then(data => {
const status = data.player_status || 'unknown';
document.getElementById('video-status-text').textContent =
status.charAt(0).toUpperCase() + status.slice(1);
const badge = document.getElementById('player-badge');
if (status === 'playing') {
badge.className = 'badge bg-success me-1';
} else if (status === 'paused') {
badge.className = 'badge bg-warning me-1';
} else {
badge.className = 'badge bg-secondary me-1';
}
})
.catch(error => {
document.getElementById('video-status-text').textContent = 'Unknown';
});
}
// Initial status update
updateSystemStatus();
updateVideoStatus();
// Periodic updates
setInterval(updateSystemStatus, 30000); // 30 seconds
setInterval(updateVideoStatus, 10000); // 10 seconds
});
</script>
{% endblock %}
\ No newline at end of file
{% extends "base.html" %}
{% block title %}Page Not Found - {{ app_name }}{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center min-vh-75 align-items-center">
<div class="col-md-6 text-center">
<div class="error-illustration mb-4">
<i class="fas fa-search fa-5x text-muted mb-3"></i>
<h1 class="display-1 fw-bold text-primary">404</h1>
</div>
<h2 class="mb-3">Page Not Found</h2>
<p class="text-muted mb-4">
The page you are looking for might have been removed, had its name changed,
or is temporarily unavailable.
</p>
<div class="d-grid gap-2 d-md-block">
<a href="{{ url_for('main.index') }}" class="btn btn-primary">
<i class="fas fa-home me-2"></i>Go to Dashboard
</a>
<button class="btn btn-outline-secondary" onclick="history.back()">
<i class="fas fa-arrow-left me-2"></i>Go Back
</button>
</div>
{% if current_user.is_authenticated and current_user.is_admin %}
<div class="mt-4 pt-4 border-top">
<h6 class="text-muted">Admin Information</h6>
<small class="text-muted d-block">
Requested URL: <code>{{ request.url }}</code>
</small>
<small class="text-muted d-block">
Endpoint: <code>{{ request.endpoint }}</code>
</small>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Auto-redirect after 10 seconds if user is inactive
setTimeout(function() {
if (confirm('Would you like to be redirected to the dashboard?')) {
window.location.href = '{{ url_for("main.index") }}';
}
}, 10000);
});
</script>
{% endblock %}
\ No newline at end of file
# Core dependencies
PyQt5==5.15.10
Flask==3.0.3
Flask-Login==0.6.3
Flask-WTF==1.2.1
Flask-JWT-Extended==4.6.0
SQLAlchemy==2.0.25
requests==2.31.0
# Database
sqlite3
# GUI and multimedia
opencv-python==4.9.0.80
Pillow==10.2.0
# Web interface
Werkzeug==3.0.1
Jinja2==3.1.3
WTForms==3.1.1
MarkupSafe==2.1.4
itsdangerous==2.1.2
# Security and authentication
cryptography==42.0.4
PyJWT==2.8.0
bcrypt==4.1.2
# Networking and API
urllib3==2.2.1
certifi==2024.2.2
charset-normalizer==3.3.2
idna==3.6
# Utilities
python-dateutil==2.8.2
six==1.16.0
click==8.1.7
colorlog==6.8.2
# Development and building
PyInstaller==6.3.0
setuptools==69.1.0
wheel==0.42.0
# Cross-platform support
psutil==5.9.8
platformdirs==4.2.0
# Configuration management
python-dotenv==1.0.1
configparser==6.0.0
# Logging
loguru==0.7.2
# Testing (optional)
pytest==8.0.0
pytest-qt==4.3.1
# Video processing
ffmpeg-python==0.2.0
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment