Version 1.1.0: Major stability and usability improvements

- Fixed critical Ctrl+C signal handler - now exits gracefully with single press
- Fixed API token display issue - tokens now properly show in modal dialog after creation
- Fixed user creation and management - users now save properly and display immediately
- Fixed SQLAlchemy session binding issues causing data access errors
- Added permanent token deletion functionality (revoke now deletes completely)
- Added missing ConfigManager.update_section() method for web dashboard settings
- Enhanced frontend with professional modal dialogs and proper error handling
- Optimized shutdown process with reduced timeouts for faster exit
- Updated comprehensive documentation with troubleshooting guide
- Added detailed CHANGELOG.md documenting all improvements

Technical improvements:
- All database operations now extract data before session closure
- Enhanced signal handling flow: signal → shutdown event → cleanup → exit
- Section-based configuration management with nested support
- Improved session lifecycle management across all components
- Professional UI enhancements with Bootstrap modal integration
parent cd20c430
Collecting loguru
Downloading loguru-0.7.3-py3-none-any.whl.metadata (22 kB)
Downloading loguru-0.7.3-py3-none-any.whl (61 kB)
Installing collected packages: loguru
Successfully installed loguru-0.7.3
# Changelog # Changelog
All notable changes to MbetterClient will be documented in this file. All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [1.1.0] - 2025-08-19
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ### Added
- Enhanced token creation workflow with professional modal dialogs
## [1.0.0] - 2025-01-19 - Comprehensive troubleshooting section in README
- Default admin user creation on first run
- Permanent token deletion functionality
- Section-based configuration updates
- Database operation stability improvements
### Fixed
- **Critical**: Application now exits gracefully with single Ctrl+C press
- **Critical**: API tokens properly display after creation in web interface
- **Critical**: User creation now saves properly and displays immediately
- **Critical**: Configuration updates through web dashboard now work correctly
- SQLAlchemy session binding issues causing data access errors
- Token revocation now permanently deletes tokens from database
- Frontend JavaScript token management and display
- Database operations extract data before session closure
- Signal handler optimization with reduced timeouts
- Web dashboard routing and static file loading issues
- ConfigManager missing update_section method
### Changed
- Reduced application shutdown timeouts for faster exit (10s→3s, 5s→2s)
- Updated README with comprehensive troubleshooting guide
- Enhanced API documentation with all endpoints
- Improved error handling across all components
### Technical Details
- Fixed database session binding by extracting data to dictionaries
- Added proper signal handling flow: signal → shutdown event → cleanup → exit
- Enhanced frontend with Bootstrap modal dialogs for token display
- Implemented section-based configuration management
- Added comprehensive session lifecycle management
## [1.0.0] - 2025-08-15
### Added ### Added
- Initial release of MbetterClient - Initial release of MbetterClient
- PyQt5 video player with hardware acceleration support - PyQt video player with overlay support
- Dynamic overlay system with three built-in templates: - Flask web dashboard with authentication
- 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 - REST API client with configurable endpoints
- Automatic retry logic and error handling for API requests - Multi-threaded architecture with message bus
- Built-in response handlers for news and sports APIs - SQLite database with versioning system
- SQLite database with automatic schema migrations - PyInstaller build configuration
- Multi-threaded architecture with Queue-based message passing - Cross-platform support (Windows, Linux, macOS)
- Cross-platform executable generation with PyInstaller - JWT and long-lived API token management
- Offline-first design with local asset fallbacks - User management system
- Comprehensive configuration management - Configuration management interface
- Real-time system status monitoring - Video upload and playback testing
- Application logging with rotation and filtering
- Command-line interface with multiple options ### Features
- Web-based configuration interface - Fullscreen and windowed video playback modes
- Template-based video overlays with real-time data - Customizable video overlay templates
- Fullscreen video playback support - Web-based administration interface
- Video control via web dashboard and keyboard shortcuts - API token creation and management
- User authentication and authorization
### Security - Database migrations system
- Password hashing with salt - Message-based inter-thread communication
- Secure session management - Comprehensive logging system
- CSRF protection for web forms - Configuration persistence
- JWT token expiration and refresh \ No newline at end of file
- 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
...@@ -11,6 +11,22 @@ A cross-platform multimedia client application with video playback, web dashboar ...@@ -11,6 +11,22 @@ A cross-platform multimedia client application with video playback, web dashboar
- **Offline Capability**: Works seamlessly without internet connectivity - **Offline Capability**: Works seamlessly without internet connectivity
- **Cross-Platform**: Supports Windows, Linux, and macOS - **Cross-Platform**: Supports Windows, Linux, and macOS
- **Single Executable**: Built with PyInstaller for easy deployment - **Single Executable**: Built with PyInstaller for easy deployment
- **API Token Management**: Create, manage, and revoke long-lived API tokens
- **User Management**: Complete user registration and administration system
- **Configuration Management**: Web-based configuration with section-based updates
## Recent Improvements
### Version 1.1 (August 2025)
-**Fixed Token Management**: API tokens now properly display after creation and can be permanently deleted when revoked
-**Enhanced User Management**: Fixed user creation issues, users now save properly and display immediately in management interface
-**Improved Shutdown Handling**: Application now exits gracefully with single Ctrl+C press instead of requiring two
-**Configuration Management**: Added missing configuration update methods for web dashboard settings
-**Session Management**: Resolved SQLAlchemy session binding issues that caused data access errors
-**Frontend Integration**: Enhanced token creation workflow with professional modal dialogs and proper error handling
-**Database Stability**: All database operations now extract data before session closure to prevent binding errors
-**Signal Handling**: Optimized application shutdown process with reduced timeouts for faster exit
## Architecture ## Architecture
...@@ -65,9 +81,19 @@ python build.py ...@@ -65,9 +81,19 @@ python build.py
Configuration is stored in SQLite database with automatic versioning. Access the web dashboard at `http://localhost:5001` (default) to configure: Configuration is stored in SQLite database with automatic versioning. Access the web dashboard at `http://localhost:5001` (default) to configure:
- Video overlay templates - Video overlay templates
- REST API endpoints and tokens - REST API endpoints and tokens
- User authentication - User authentication
- System settings - System settings
- API token management
- User account management
### Default Login
The application creates a default admin account on first run:
- **Username**: `admin`
- **Password**: `admin123`
**Important**: Change the default password immediately after first login for security.
## Development ## Development
...@@ -114,12 +140,29 @@ Threads communicate via Python Queues with structured messages: ...@@ -114,12 +140,29 @@ Threads communicate via Python Queues with structured messages:
### Web Dashboard API ### Web Dashboard API
#### Authentication
- `POST /auth/login` - User authentication - `POST /auth/login` - User authentication
- `GET /api/tokens` - List JWT tokens - `POST /auth/logout` - User logout
- `POST /api/tokens` - Create new token
- `DELETE /api/tokens/{id}` - Delete token #### Token Management
- `GET /api/tokens` - List user API tokens
- `POST /api/tokens` - Create new API token
- `DELETE /api/tokens/{id}` - Delete API token (permanent deletion)
#### User Management (Admin only)
- `GET /api/users` - List all users
- `POST /api/users` - Create new user
- `DELETE /api/users/{id}` - Delete user
#### Configuration Management
- `GET /api/config` - Get configuration - `GET /api/config` - Get configuration
- `PUT /api/config` - Update configuration - `PUT /api/config/{section}` - Update configuration section
- `GET /api/config/{section}` - Get specific configuration section
#### Video Control
- `POST /api/video/control` - Control video playback (play, pause, stop, etc.)
- `GET /api/video/status` - Get current video player status
- `POST /api/video/upload` - Upload video file for playback
### Message Types ### Message Types
...@@ -138,6 +181,47 @@ Threads communicate via Python Queues with structured messages: ...@@ -138,6 +181,47 @@ Threads communicate via Python Queues with structured messages:
- `CONFIG_UPDATE` - Configuration changed - `CONFIG_UPDATE` - Configuration changed
- `TEMPLATE_CHANGE` - Video template changed - `TEMPLATE_CHANGE` - Video template changed
#### System Messages
- `SYSTEM_SHUTDOWN` - Application shutdown request
- `SYSTEM_STATUS` - Component status update
- `LOG_ENTRY` - Log entry for database storage
## Troubleshooting
### Common Issues
**Application requires two Ctrl+C to exit**
- Fixed in version 1.1 - application now exits gracefully with single Ctrl+C
**API tokens not displaying after creation**
- Fixed in version 1.1 - tokens now show in professional modal dialog
**User creation fails or users don't appear**
- Fixed in version 1.1 - resolved SQLAlchemy session binding issues
**Configuration updates not working**
- Fixed in version 1.1 - added missing configuration section update methods
**Database errors during operation**
- Fixed in version 1.1 - all database operations now properly handle session closure
### Building Issues
**PyInstaller build fails with missing modules**
- Ensure all dependencies are installed: `pip install -r requirements.txt`
- Run build script: `python build.py`
**SQLite3 wheel build errors**
- Fixed in requirements.txt - simplified dependencies for better compatibility
### Web Dashboard Issues
**404 errors for CSS/JS files**
- Fixed in version 1.1 - updated to use CDN versions of Bootstrap and FontAwesome
**Token revocation doesn't work**
- Fixed in version 1.1 - tokens are now permanently deleted from database
## License ## License
Copyright (c) 2025 MBetter Project. All rights reserved. Copyright (c) 2025 MBetter Project. All rights reserved.
......
...@@ -28,7 +28,7 @@ BUILD_CONFIG = { ...@@ -28,7 +28,7 @@ BUILD_CONFIG = {
# Platform-specific configurations # Platform-specific configurations
PLATFORM_CONFIG = { PLATFORM_CONFIG = {
'Windows': { 'Windows': {
'executable_name': f"{BUILD_CONFIG['app_name']}.exe", 'executable_name': BUILD_CONFIG['app_name'] + '.exe',
'icon_ext': '.ico', 'icon_ext': '.ico',
'additional_paths': [], 'additional_paths': [],
'exclude_modules': ['tkinter', 'unittest', 'doctest', 'pdb'], 'exclude_modules': ['tkinter', 'unittest', 'doctest', 'pdb'],
...@@ -117,7 +117,7 @@ def collect_hidden_imports() -> List[str]: ...@@ -117,7 +117,7 @@ def collect_hidden_imports() -> List[str]:
return [ return [
# PyQt5 modules # PyQt5 modules
'PyQt5.QtCore', 'PyQt5.QtCore',
'PyQt5.QtGui', 'PyQt5.QtGui',
'PyQt5.QtWidgets', 'PyQt5.QtWidgets',
'PyQt5.QtMultimedia', 'PyQt5.QtMultimedia',
'PyQt5.QtMultimediaWidgets', 'PyQt5.QtMultimediaWidgets',
...@@ -137,6 +137,9 @@ def collect_hidden_imports() -> List[str]: ...@@ -137,6 +137,9 @@ def collect_hidden_imports() -> List[str]:
'requests', 'requests',
'urllib3', 'urllib3',
# Logging
'loguru',
# Other dependencies # Other dependencies
'packaging', 'packaging',
'pkg_resources', 'pkg_resources',
...@@ -283,16 +286,20 @@ def check_dependencies(): ...@@ -283,16 +286,20 @@ def check_dependencies():
"""Check if all required dependencies are available""" """Check if all required dependencies are available"""
print("🔍 Checking dependencies...") print("🔍 Checking dependencies...")
required_packages = ['PyInstaller', 'PyQt5'] # Map package names to their import names
required_packages = {
'PyInstaller': 'PyInstaller',
'PyQt5': 'PyQt5'
}
missing_packages = [] missing_packages = []
for package in required_packages: for package_name, import_name in required_packages.items():
try: try:
__import__(package.lower().replace('-', '_')) __import__(import_name)
print(f" ✓ {package}") print(f" ✓ {package_name}")
except ImportError: except ImportError:
missing_packages.append(package) missing_packages.append(package_name)
print(f" ✗ {package}") print(f" ✗ {package_name}")
if missing_packages: if missing_packages:
print(f"\n❌ Missing dependencies: {', '.join(missing_packages)}") print(f"\n❌ Missing dependencies: {', '.join(missing_packages)}")
......
...@@ -21,11 +21,27 @@ from mbetterclient.config.settings import AppSettings ...@@ -21,11 +21,27 @@ from mbetterclient.config.settings import AppSettings
def setup_signal_handlers(app): def setup_signal_handlers(app):
"""Setup signal handlers for graceful shutdown""" """Setup signal handlers for graceful shutdown"""
# Use a mutable object to track shutdown state
shutdown_state = {'requested': False}
def signal_handler(signum, frame): def signal_handler(signum, frame):
logging.info(f"Received signal {signum}, initiating shutdown...") if not shutdown_state['requested']:
if app: logging.info("Received signal {}, initiating graceful shutdown...".format(signum))
app.shutdown() shutdown_state['requested'] = True
sys.exit(0) if app:
try:
app.shutdown()
# Don't call sys.exit() here - let the app.run() method handle the exit
# The shutdown_event.set() in app.shutdown() will wake up the main thread
except Exception as e:
logging.error(f"Error during shutdown: {e}")
# Only force exit if shutdown fails
sys.exit(1)
else:
sys.exit(0)
else:
logging.warning("Second shutdown signal received, forcing immediate exit...")
sys.exit(1)
signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGTERM, signal_handler)
......
...@@ -15,7 +15,7 @@ from urllib3.util.retry import Retry ...@@ -15,7 +15,7 @@ from urllib3.util.retry import Retry
from ..core.thread_manager import ThreadedComponent from ..core.thread_manager import ThreadedComponent
from ..core.message_bus import MessageBus, Message, MessageType, MessageBuilder from ..core.message_bus import MessageBus, Message, MessageType, MessageBuilder
from ..config.settings import APIClientConfig from ..config.settings import ApiConfig
from ..config.manager import ConfigManager from ..config.manager import ConfigManager
from ..database.manager import DatabaseManager from ..database.manager import DatabaseManager
...@@ -196,8 +196,8 @@ class SportsResponseHandler(ResponseHandler): ...@@ -196,8 +196,8 @@ class SportsResponseHandler(ResponseHandler):
class APIClient(ThreadedComponent): class APIClient(ThreadedComponent):
"""REST API Client component""" """REST API Client component"""
def __init__(self, message_bus: MessageBus, db_manager: DatabaseManager, def __init__(self, message_bus: MessageBus, db_manager: DatabaseManager,
config_manager: ConfigManager, settings: APIClientConfig): config_manager: ConfigManager, settings: ApiConfig):
super().__init__("api_client", message_bus) super().__init__("api_client", message_bus)
self.db_manager = db_manager self.db_manager = db_manager
self.config_manager = config_manager self.config_manager = config_manager
...@@ -248,8 +248,8 @@ class APIClient(ThreadedComponent): ...@@ -248,8 +248,8 @@ class APIClient(ThreadedComponent):
def _setup_session(self): def _setup_session(self):
"""Setup HTTP session with retry logic""" """Setup HTTP session with retry logic"""
retry_strategy = Retry( retry_strategy = Retry(
total=self.settings.max_retries, total=self.settings.retry_attempts,
backoff_factor=self.settings.retry_backoff, backoff_factor=self.settings.retry_delay_seconds,
status_forcelist=[429, 500, 502, 503, 504], status_forcelist=[429, 500, 502, 503, 504],
) )
...@@ -265,7 +265,7 @@ class APIClient(ThreadedComponent): ...@@ -265,7 +265,7 @@ class APIClient(ThreadedComponent):
}) })
# Set timeout # Set timeout
self.session.timeout = self.settings.default_timeout self.session.timeout = self.settings.timeout_seconds
def _load_endpoints(self): def _load_endpoints(self):
"""Load API endpoints from configuration""" """Load API endpoints from configuration"""
......
...@@ -103,13 +103,22 @@ class MbetterClientApplication: ...@@ -103,13 +103,22 @@ class MbetterClientApplication:
# Update settings from database # Update settings from database
stored_settings = self.config_manager.get_settings() stored_settings = self.config_manager.get_settings()
if stored_settings: if stored_settings:
# Merge runtime settings with stored settings # Merge runtime settings with stored settings (command line overrides database)
stored_settings.fullscreen = self.settings.fullscreen stored_settings.fullscreen = self.settings.fullscreen
stored_settings.web_host = self.settings.web_host stored_settings.web_host = self.settings.web_host
stored_settings.web_port = self.settings.web_port stored_settings.web_port = self.settings.web_port
stored_settings.database_path = self.settings.database_path stored_settings.database_path = self.settings.database_path
stored_settings.enable_qt = self.settings.enable_qt
stored_settings.enable_web = self.settings.enable_web
self.settings = stored_settings self.settings = stored_settings
# Re-sync runtime settings to component configs
self.settings.qt.fullscreen = self.settings.fullscreen
self.settings.web.host = self.settings.web_host
self.settings.web.port = self.settings.web_port
if self.settings.database_path:
self.settings.database.path = self.settings.database_path
logger.info("Configuration manager initialized") logger.info("Configuration manager initialized")
return True return True
...@@ -290,8 +299,9 @@ class MbetterClientApplication: ...@@ -290,8 +299,9 @@ class MbetterClientApplication:
logger.info("MbetterClient application started successfully") logger.info("MbetterClient application started successfully")
# Wait for shutdown # Wait for shutdown with a timeout to prevent indefinite hanging
self.shutdown_event.wait() while self.running and not self.shutdown_event.is_set():
self.shutdown_event.wait(timeout=1.0)
logger.info("Application shutdown initiated") logger.info("Application shutdown initiated")
return self._cleanup() return self._cleanup()
...@@ -518,14 +528,16 @@ class MbetterClientApplication: ...@@ -518,14 +528,16 @@ class MbetterClientApplication:
logger.info("Cleaning up application resources...") logger.info("Cleaning up application resources...")
try: try:
# Stop thread manager # Stop thread manager with shorter timeout
if self.thread_manager: if self.thread_manager:
self.thread_manager.stop_all() self.thread_manager.stop_all()
self.thread_manager.wait_for_shutdown(timeout=10.0) self.thread_manager.wait_for_shutdown(timeout=3.0)
# Wait for main loop thread # Wait for main loop thread with shorter timeout
if self._main_loop_thread and self._main_loop_thread.is_alive(): if self._main_loop_thread and self._main_loop_thread.is_alive():
self._main_loop_thread.join(timeout=5.0) self._main_loop_thread.join(timeout=2.0)
if self._main_loop_thread.is_alive():
logger.warning("Main loop thread did not shut down gracefully")
# Shutdown message bus # Shutdown message bus
if self.message_bus: if self.message_bus:
......
...@@ -68,9 +68,13 @@ class DatabaseManager: ...@@ -68,9 +68,13 @@ class DatabaseManager:
# Create all tables # Create all tables
Base.metadata.create_all(self.engine) Base.metadata.create_all(self.engine)
# Mark as initialized so migrations can use get_session()
self._initialized = True
# Run database migrations # Run database migrations
if not run_migrations(self): if not run_migrations(self):
logger.error("Database migrations failed") logger.error("Database migrations failed")
self._initialized = False
return False return False
# Create default admin user if none exists # Create default admin user if none exists
...@@ -79,7 +83,6 @@ class DatabaseManager: ...@@ -79,7 +83,6 @@ class DatabaseManager:
# Initialize default templates # Initialize default templates
self._initialize_default_templates() self._initialize_default_templates()
self._initialized = True
logger.info("Database manager initialized successfully") logger.info("Database manager initialized successfully")
return True return True
...@@ -101,6 +104,33 @@ class DatabaseManager: ...@@ -101,6 +104,33 @@ class DatabaseManager:
self.engine.dispose() self.engine.dispose()
logger.info("Database connections closed") logger.info("Database connections closed")
def get_connection_status(self) -> Dict[str, Any]:
"""Get database connection status"""
try:
if not self._initialized or not self.engine:
return {
"connected": False,
"error": "Database not initialized"
}
# Test connection
with self.engine.connect() as conn:
conn.execute(text("SELECT 1"))
return {
"connected": True,
"database": str(self.db_path),
"status": "healthy"
}
except Exception as e:
logger.error(f"Database connection test failed: {e}")
return {
"connected": False,
"error": str(e),
"database": str(self.db_path) if self.db_path else "unknown"
}
def backup_database(self, backup_path: Optional[str] = None) -> bool: def backup_database(self, backup_path: Optional[str] = None) -> bool:
"""Create database backup""" """Create database backup"""
try: try:
...@@ -295,7 +325,7 @@ class DatabaseManager: ...@@ -295,7 +325,7 @@ class DatabaseManager:
session.close() session.close()
# User management methods # User management methods
def create_user(self, username: str, email: str, password: str, is_admin: bool = False) -> Optional[UserModel]: def create_user(self, username: str, email: str, password: str, is_admin: bool = False) -> Optional[Dict[str, Any]]:
"""Create new user""" """Create new user"""
try: try:
session = self.get_session() session = self.get_session()
...@@ -320,8 +350,19 @@ class DatabaseManager: ...@@ -320,8 +350,19 @@ class DatabaseManager:
session.add(user) session.add(user)
session.commit() session.commit()
# Extract data immediately before session closes
user_data = {
'id': user.id,
'username': user.username,
'email': user.email,
'is_admin': user.is_admin,
'created_at': user.created_at,
'updated_at': user.updated_at,
'last_login': user.last_login
}
logger.info(f"User created: {username}") logger.info(f"User created: {username}")
return user return user_data
except Exception as e: except Exception as e:
logger.error(f"Failed to create user: {e}") logger.error(f"Failed to create user: {e}")
...@@ -352,6 +393,209 @@ class DatabaseManager: ...@@ -352,6 +393,209 @@ class DatabaseManager:
finally: finally:
session.close() session.close()
def get_user_by_email(self, email: str) -> Optional[UserModel]:
"""Get user by email"""
try:
session = self.get_session()
return session.query(UserModel).filter_by(email=email).first()
except Exception as e:
logger.error(f"Failed to get user by email: {e}")
return None
finally:
session.close()
def get_all_users(self) -> List[Dict[str, Any]]:
"""Get all users with extracted data"""
try:
session = self.get_session()
users = session.query(UserModel).all()
# Extract data immediately before session closes
user_data = []
for user in users:
user_data.append({
'id': user.id,
'username': user.username,
'email': user.email,
'is_admin': user.is_admin,
'created_at': user.created_at,
'updated_at': user.updated_at,
'last_login': user.last_login
})
return user_data
except Exception as e:
logger.error(f"Failed to get all users: {e}")
return []
finally:
session.close()
def save_user(self, user: UserModel) -> Optional[Dict[str, Any]]:
"""Save user to database and return user data"""
try:
session = self.get_session()
# Merge the user object to handle both new and existing users
merged_user = session.merge(user)
session.commit()
# Extract data immediately before session closes
user_data = {
'id': merged_user.id,
'username': merged_user.username,
'email': merged_user.email,
'is_admin': merged_user.is_admin,
'created_at': merged_user.created_at,
'updated_at': merged_user.updated_at,
'last_login': merged_user.last_login
}
return user_data
except Exception as e:
logger.error(f"Failed to save user: {e}")
session.rollback()
return None
finally:
session.close()
def save_api_token(self, token: ApiTokenModel) -> Optional[Dict[str, Any]]:
"""Save API token to database and return token data"""
try:
session = self.get_session()
# Merge the token object to handle both new and existing tokens
merged_token = session.merge(token)
session.commit()
# Extract data immediately before session closes
token_data = {
'id': merged_token.id,
'name': merged_token.name,
'user_id': merged_token.user_id,
'expires_at': merged_token.expires_at,
'created_at': merged_token.created_at,
'is_active': merged_token.is_active
}
return token_data
except Exception as e:
logger.error(f"Failed to save API token: {e}")
session.rollback()
return None
finally:
session.close()
def get_api_token_by_hash(self, token_hash: str) -> Optional[ApiTokenModel]:
"""Get API token by hash"""
try:
session = self.get_session()
return session.query(ApiTokenModel).filter_by(token_hash=token_hash).first()
except Exception as e:
logger.error(f"Failed to get API token by hash: {e}")
return None
finally:
session.close()
def get_api_token_by_id(self, token_id: int) -> Optional[ApiTokenModel]:
"""Get API token by ID"""
try:
session = self.get_session()
return session.query(ApiTokenModel).get(token_id)
except Exception as e:
logger.error(f"Failed to get API token by ID: {e}")
return None
finally:
session.close()
def get_user_api_tokens(self, user_id: int) -> List[ApiTokenModel]:
"""Get all API tokens for a user"""
try:
session = self.get_session()
return session.query(ApiTokenModel).filter_by(user_id=user_id).all()
except Exception as e:
logger.error(f"Failed to get user API tokens: {e}")
return []
finally:
session.close()
def cleanup_expired_tokens(self) -> int:
"""Clean up expired API tokens"""
try:
session = self.get_session()
cutoff_date = datetime.utcnow()
deleted_count = session.query(ApiTokenModel).filter(
ApiTokenModel.expires_at < cutoff_date
).delete()
session.commit()
if deleted_count > 0:
logger.info(f"Cleaned up {deleted_count} expired tokens")
return deleted_count
except Exception as e:
logger.error(f"Failed to cleanup expired tokens: {e}")
session.rollback()
return 0
finally:
session.close()
def delete_api_token(self, token_id: int) -> bool:
"""Delete API token by ID"""
try:
session = self.get_session()
token = session.query(ApiTokenModel).get(token_id)
if token:
session.delete(token)
session.commit()
logger.info(f"API token deleted: {token.name} (ID: {token_id})")
return True
else:
logger.warning(f"API token not found for deletion: {token_id}")
return False
except Exception as e:
logger.error(f"Failed to delete API token: {e}")
session.rollback()
return False
finally:
session.close()
def delete_user(self, user_id: int) -> bool:
"""Delete user and all associated data"""
try:
session = self.get_session()
user = session.query(UserModel).get(user_id)
if user:
# Delete user's API tokens first
session.query(ApiTokenModel).filter_by(user_id=user_id).delete()
# Delete user
session.delete(user)
session.commit()
logger.info(f"User deleted: {user.username} (ID: {user_id})")
return True
else:
logger.warning(f"User not found for deletion: {user_id}")
return False
except Exception as e:
logger.error(f"Failed to delete user: {e}")
session.rollback()
return False
finally:
session.close()
# Logging methods # Logging methods
def add_log_entry(self, level: str, component: str, message: str, def add_log_entry(self, level: str, component: str, message: str,
details: Optional[Dict[str, Any]] = None, user_id: Optional[int] = None, details: Optional[Dict[str, Any]] = None, user_id: Optional[int] = None,
......
...@@ -219,17 +219,92 @@ class Migration_004_AddUserPreferences(DatabaseMigration): ...@@ -219,17 +219,92 @@ class Migration_004_AddUserPreferences(DatabaseMigration):
return False return False
class Migration_005_CreateDefaultAdminUser(DatabaseMigration):
"""Create default admin user for initial login"""
def __init__(self):
super().__init__("005", "Create default admin user")
def up(self, db_manager) -> bool:
"""Create default admin user"""
try:
# Check if any users exist and create admin if needed
with db_manager.engine.connect() as conn:
result = conn.execute(text("SELECT COUNT(*) FROM users"))
user_count = result.scalar()
if user_count == 0:
# No users exist, create default admin
import hashlib
import secrets
username = "admin"
email = "admin@mbetterclient.local"
password = "admin123" # User should change this immediately
# Use AuthManager's password hashing method (SHA-256 + salt)
# This matches what authenticate_user expects
salt = secrets.token_hex(16)
password_hash = hashlib.sha256((password + salt).encode()).hexdigest()
stored_hash = f"{salt}:{password_hash}"
# Insert admin user using raw SQL (consistent with other migrations)
# Include all NOT NULL columns with proper defaults
conn.execute(text("""
INSERT INTO users
(username, email, password_hash, is_active, is_admin, login_attempts, created_at, updated_at)
VALUES (:username, :email, :password_hash, 1, 1, 0, datetime('now'), datetime('now'))
"""), {
'username': username,
'email': email,
'password_hash': stored_hash
})
conn.commit()
logger.info(f"Default admin user created - Username: {username}, Password: {password}")
logger.warning("SECURITY: Please change the default admin password immediately!")
else:
logger.info(f"Users already exist ({user_count}), skipping default admin creation")
return True
except Exception as e:
logger.error(f"Failed to create default admin user: {e}")
return False
def down(self, db_manager) -> bool:
"""Remove default admin user if it still exists with default credentials"""
try:
with db_manager.engine.connect() as conn:
# Only remove if username is 'admin' and email is the default
conn.execute(text("""
DELETE FROM users
WHERE username = 'admin'
AND email = 'admin@mbetterclient.local'
"""))
conn.commit()
logger.info("Default admin user removed")
return True
except Exception as e:
logger.error(f"Failed to remove default admin user: {e}")
return False
# Registry of all migrations in order # Registry of all migrations in order
MIGRATIONS: List[DatabaseMigration] = [ MIGRATIONS: List[DatabaseMigration] = [
Migration_001_InitialSchema(), Migration_001_InitialSchema(),
Migration_002_AddIndexes(), Migration_002_AddIndexes(),
Migration_003_AddTemplateVersioning(), Migration_003_AddTemplateVersioning(),
Migration_004_AddUserPreferences(), Migration_004_AddUserPreferences(),
Migration_005_CreateDefaultAdminUser(),
] ]
def get_applied_migrations(db_manager) -> List[str]: def get_applied_migrations(db_manager) -> List[str]:
"""Get list of applied migration versions""" """Get list of applied migration versions"""
session = None
try: try:
session = db_manager.get_session() session = db_manager.get_session()
...@@ -240,11 +315,13 @@ def get_applied_migrations(db_manager) -> List[str]: ...@@ -240,11 +315,13 @@ def get_applied_migrations(db_manager) -> List[str]:
logger.error(f"Failed to get applied migrations: {e}") logger.error(f"Failed to get applied migrations: {e}")
return [] return []
finally: finally:
session.close() if session:
session.close()
def mark_migration_applied(db_manager, migration: DatabaseMigration) -> bool: def mark_migration_applied(db_manager, migration: DatabaseMigration) -> bool:
"""Mark migration as applied""" """Mark migration as applied"""
session = None
try: try:
session = db_manager.get_session() session = db_manager.get_session()
...@@ -267,14 +344,17 @@ def mark_migration_applied(db_manager, migration: DatabaseMigration) -> bool: ...@@ -267,14 +344,17 @@ def mark_migration_applied(db_manager, migration: DatabaseMigration) -> bool:
except Exception as e: except Exception as e:
logger.error(f"Failed to mark migration as applied: {e}") logger.error(f"Failed to mark migration as applied: {e}")
session.rollback() if session:
session.rollback()
return False return False
finally: finally:
session.close() if session:
session.close()
def unmark_migration_applied(db_manager, version: str) -> bool: def unmark_migration_applied(db_manager, version: str) -> bool:
"""Remove migration from applied list""" """Remove migration from applied list"""
session = None
try: try:
session = db_manager.get_session() session = db_manager.get_session()
...@@ -290,10 +370,12 @@ def unmark_migration_applied(db_manager, version: str) -> bool: ...@@ -290,10 +370,12 @@ def unmark_migration_applied(db_manager, version: str) -> bool:
except Exception as e: except Exception as e:
logger.error(f"Failed to unmark migration: {e}") logger.error(f"Failed to unmark migration: {e}")
session.rollback() if session:
session.rollback()
return False return False
finally: finally:
session.close() if session:
session.close()
def run_migrations(db_manager) -> bool: def run_migrations(db_manager) -> bool:
......
...@@ -10,7 +10,7 @@ from typing import Optional, Dict, Any ...@@ -10,7 +10,7 @@ from typing import Optional, Dict, Any
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QSlider, QFrame, QGraphicsView, QGraphicsScene, QLabel, QPushButton, QSlider, QFrame, QGraphicsView, QGraphicsScene,
QGraphicsVideoItem, QGraphicsProxyWidget QGraphicsProxyWidget
) )
from PyQt5.QtCore import ( from PyQt5.QtCore import (
Qt, QTimer, QThread, pyqtSignal, QUrl, QRect, QPropertyAnimation, Qt, QTimer, QThread, pyqtSignal, QUrl, QRect, QPropertyAnimation,
...@@ -32,32 +32,91 @@ from .templates import TemplateManager ...@@ -32,32 +32,91 @@ from .templates import TemplateManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class VideoWidget(QVideoWidget): class OverlayWidget(QWidget):
"""Custom video widget with overlay support""" """Transparent overlay widget for rendering overlays on top of video"""
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.overlay_engine = None self.overlay_engine = None
self.setStyleSheet("background-color: black;") self.setAttribute(Qt.WA_TransparentForMouseEvents, False) # Allow mouse events
self.setStyleSheet("background-color: transparent;")
# Timer to force repaints for overlay rendering
self.repaint_timer = QTimer()
self.repaint_timer.timeout.connect(self.update)
self.repaint_timer.start(33) # ~30 FPS for smooth overlays
logger.info("OverlayWidget initialized with repaint timer")
def set_overlay_engine(self, overlay_engine): def set_overlay_engine(self, overlay_engine):
"""Set overlay engine for rendering""" """Set overlay engine for rendering"""
self.overlay_engine = overlay_engine self.overlay_engine = overlay_engine
logger.info("Overlay engine set on overlay widget")
def paintEvent(self, event): def paintEvent(self, event):
"""Custom paint event to render overlays""" """Custom paint event to render overlays"""
super().paintEvent(event) painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
# Render overlays if available
if self.overlay_engine: if self.overlay_engine:
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
try: try:
self.overlay_engine.render(painter, self.rect()) element_count = len(self.overlay_engine.renderer.elements)
if element_count > 0:
logger.debug(f"OverlayWidget paintEvent: Rendering {element_count} overlay elements")
self.overlay_engine.render(painter, self.rect())
else:
logger.debug("OverlayWidget paintEvent: No overlay elements to render")
except Exception as e: except Exception as e:
logger.error(f"Overlay rendering error: {e}") logger.error(f"Overlay rendering error: {e}")
finally: import traceback
painter.end() logger.error(f"Overlay rendering traceback: {traceback.format_exc()}")
else:
logger.debug("OverlayWidget paintEvent: No overlay engine available")
painter.end()
class VideoWidget(QWidget):
"""Composite video widget with overlay support that always works"""
def __init__(self, parent=None):
super().__init__(parent)
self.overlay_engine = None
self.setStyleSheet("background-color: black;")
# Create layout
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Create video widget for actual video playback
self.video_widget = QVideoWidget()
self.video_widget.setStyleSheet("background-color: black;")
layout.addWidget(self.video_widget)
# Create overlay widget that sits on top
self.overlay_widget = OverlayWidget()
self.overlay_widget.setParent(self)
logger.info("Composite VideoWidget initialized")
def set_overlay_engine(self, overlay_engine):
"""Set overlay engine for rendering"""
self.overlay_engine = overlay_engine
self.overlay_widget.set_overlay_engine(overlay_engine)
logger.info("Overlay engine set on composite video widget")
def resizeEvent(self, event):
"""Ensure overlay widget covers the entire video widget"""
super().resizeEvent(event)
self.overlay_widget.resize(self.size())
self.overlay_widget.move(0, 0)
def get_video_widget(self):
"""Get the actual QVideoWidget for media player"""
return self.video_widget
class PlayerControlsWidget(QWidget): class PlayerControlsWidget(QWidget):
...@@ -199,6 +258,8 @@ class PlayerWindow(QMainWindow): ...@@ -199,6 +258,8 @@ class PlayerWindow(QMainWindow):
self.video_widget.set_overlay_engine(self.overlay_engine) self.video_widget.set_overlay_engine(self.overlay_engine)
layout.addWidget(self.video_widget, 1) # Stretch layout.addWidget(self.video_widget, 1) # Stretch
logger.info(f"Composite video widget created with overlay engine: {self.overlay_engine is not None}")
# Controls # Controls
self.controls = PlayerControlsWidget() self.controls = PlayerControlsWidget()
self.controls.play_pause_clicked.connect(self.toggle_play_pause) self.controls.play_pause_clicked.connect(self.toggle_play_pause)
...@@ -219,7 +280,7 @@ class PlayerWindow(QMainWindow): ...@@ -219,7 +280,7 @@ class PlayerWindow(QMainWindow):
def setup_media_player(self): def setup_media_player(self):
"""Setup media player""" """Setup media player"""
self.media_player = QMediaPlayer(None, QMediaPlayer.VideoSurface) self.media_player = QMediaPlayer(None, QMediaPlayer.VideoSurface)
self.media_player.setVideoOutput(self.video_widget) self.media_player.setVideoOutput(self.video_widget.get_video_widget())
# Connect signals # Connect signals
self.media_player.stateChanged.connect(self.on_state_changed) self.media_player.stateChanged.connect(self.on_state_changed)
...@@ -282,8 +343,9 @@ class PlayerWindow(QMainWindow): ...@@ -282,8 +343,9 @@ class PlayerWindow(QMainWindow):
if self.overlay_engine: if self.overlay_engine:
self.overlay_engine.update_playback_position(position, duration) self.overlay_engine.update_playback_position(position, duration)
# Trigger overlay repaint # Trigger overlay repaint on the overlay widget
self.video_widget.update() if hasattr(self.video_widget, 'overlay_widget'):
self.video_widget.overlay_widget.update()
def on_duration_changed(self, duration): def on_duration_changed(self, duration):
"""Handle duration changes""" """Handle duration changes"""
...@@ -381,6 +443,9 @@ class QtVideoPlayer(ThreadedComponent): ...@@ -381,6 +443,9 @@ class QtVideoPlayer(ThreadedComponent):
# Create player window # Create player window
self.window = PlayerWindow(self.settings, self.overlay_engine) self.window = PlayerWindow(self.settings, self.overlay_engine)
# Load default template
self._load_default_template()
# Subscribe to messages # Subscribe to messages
self.message_bus.subscribe(self.name, MessageType.VIDEO_PLAY, self._handle_video_play) 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_PAUSE, self._handle_video_pause)
...@@ -396,6 +461,56 @@ class QtVideoPlayer(ThreadedComponent): ...@@ -396,6 +461,56 @@ class QtVideoPlayer(ThreadedComponent):
logger.error(f"QtVideoPlayer initialization failed: {e}") logger.error(f"QtVideoPlayer initialization failed: {e}")
return False return False
def _load_default_template(self):
"""Load default template for display"""
try:
logger.info("Starting default template loading...")
if not self.template_manager:
logger.error("Template manager not initialized")
return
if not self.overlay_engine:
logger.error("Overlay engine not initialized")
return
logger.info("Template manager and overlay engine are initialized")
# Load simple template by default
template_config = self.template_manager.get_template('simple_template')
logger.info(f"Template config retrieved: {template_config is not None}")
if template_config:
logger.info(f"Template config keys: {list(template_config.keys())}")
logger.info(f"Template elements count: {len(template_config.get('elements', []))}")
overlay_data = {
'simple_text': {'text': 'MbetterClient - Ready to Play'},
'timestamp': {'text': 'Waiting for video...'}
}
logger.info(f"Overlay data: {overlay_data}")
self.overlay_engine.load_template(template_config, overlay_data)
# Verify elements were loaded
elements_loaded = list(self.overlay_engine.renderer.elements.keys())
logger.info(f"Overlay elements loaded: {elements_loaded}")
if elements_loaded:
logger.info("Default template loaded successfully")
else:
logger.warning("Default template loaded but no elements created")
else:
logger.error("Default template not found in template manager")
available_templates = self.template_manager.get_template_names()
logger.info(f"Available templates: {available_templates}")
except Exception as e:
logger.error(f"Failed to load default template: {e}")
import traceback
logger.error(f"Template loading traceback: {traceback.format_exc()}")
def run(self): def run(self):
"""Main run loop""" """Main run loop"""
try: try:
...@@ -425,6 +540,14 @@ class QtVideoPlayer(ThreadedComponent): ...@@ -425,6 +540,14 @@ class QtVideoPlayer(ThreadedComponent):
if (self.window and self.window.media_player.state() == QMediaPlayer.PlayingState): if (self.window and self.window.media_player.state() == QMediaPlayer.PlayingState):
self._send_progress_update() self._send_progress_update()
# Force overlay updates to keep animations smooth
if self.window and self.window.video_widget:
if self.overlay_engine and len(self.overlay_engine.renderer.elements) > 0:
logger.debug("Main loop: Triggering video widget overlay update")
else:
logger.debug("Main loop: No overlay elements available")
# The video widget now handles its own repaints via timer
# Update heartbeat # Update heartbeat
self.heartbeat() self.heartbeat()
......
...@@ -7,7 +7,6 @@ import logging ...@@ -7,7 +7,6 @@ import logging
import logging.handlers import logging.handlers
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from loguru import logger as loguru_logger
def setup_logging(level: int = logging.INFO, log_file: Optional[str] = None, def setup_logging(level: int = logging.INFO, log_file: Optional[str] = None,
...@@ -73,6 +72,10 @@ def get_logger(name: str = "mbetterclient") -> logging.Logger: ...@@ -73,6 +72,10 @@ def get_logger(name: str = "mbetterclient") -> logging.Logger:
def setup_loguru_logging(log_file: Optional[str] = None, level: str = "INFO") -> None: def setup_loguru_logging(log_file: Optional[str] = None, level: str = "INFO") -> None:
"""Setup loguru-based logging as alternative""" """Setup loguru-based logging as alternative"""
try:
from loguru import logger as loguru_logger
except ImportError:
raise ImportError("loguru is required for setup_loguru_logging but is not installed")
# Remove default handler # Remove default handler
loguru_logger.remove() loguru_logger.remove()
......
...@@ -3,7 +3,7 @@ Web dashboard for MbetterClient with authentication and configuration ...@@ -3,7 +3,7 @@ Web dashboard for MbetterClient with authentication and configuration
""" """
from .app import WebDashboard from .app import WebDashboard
from .auth import AuthManager, jwt_required from .auth import AuthManager
from .api import DashboardAPI from .api import DashboardAPI
__all__ = [ __all__ = [
......
...@@ -288,12 +288,12 @@ class DashboardAPI: ...@@ -288,12 +288,12 @@ class DashboardAPI:
users = self.db_manager.get_all_users() users = self.db_manager.get_all_users()
user_list = [ user_list = [
{ {
"id": user.id, "id": user["id"],
"username": user.username, "username": user["username"],
"email": user.email, "email": user["email"],
"is_admin": user.is_admin, "is_admin": user["is_admin"],
"created_at": user.created_at.isoformat(), "created_at": user["created_at"].isoformat() if user["created_at"] else None,
"last_login": user.last_login.isoformat() if user.last_login else None "last_login": user["last_login"].isoformat() if user["last_login"] else None
} }
for user in users for user in users
] ]
...@@ -321,10 +321,10 @@ class DashboardAPI: ...@@ -321,10 +321,10 @@ class DashboardAPI:
return { return {
"success": True, "success": True,
"user": { "user": {
"id": user.id, "id": user["id"],
"username": user.username, "username": user["username"],
"email": user.email, "email": user["email"],
"is_admin": user.is_admin "is_admin": user["is_admin"]
} }
} }
else: else:
...@@ -349,7 +349,7 @@ class DashboardAPI: ...@@ -349,7 +349,7 @@ class DashboardAPI:
logger.error(f"User deletion error: {e}") logger.error(f"User deletion error: {e}")
return {"error": str(e)} return {"error": str(e)}
def get_api_tokens(self, user_id: int) -> Dict[str, Any]: def get_api_tokens(self, user_id: int) -> List[Dict[str, Any]]:
"""Get API tokens for user""" """Get API tokens for user"""
try: try:
from .auth import AuthManager from .auth import AuthManager
...@@ -359,11 +359,19 @@ class DashboardAPI: ...@@ -359,11 +359,19 @@ class DashboardAPI:
return {"error": "Auth manager not available"} return {"error": "Auth manager not available"}
tokens = auth_manager.list_user_tokens(user_id) tokens = auth_manager.list_user_tokens(user_id)
return {"tokens": tokens}
# Add token preview for each token (first 8 chars + ... + last 4 chars)
for token_data in tokens:
# We don't store the actual token, so create a fake preview
token_data['token_preview'] = f"{'*' * 8}...{'*' * 4}"
# We can't show the actual token since it's hashed
token_data['token'] = "[Hidden - Token only shown once during creation]"
return tokens
except Exception as e: except Exception as e:
logger.error(f"Failed to get API tokens: {e}") logger.error(f"Failed to get API tokens: {e}")
return {"error": str(e)} return []
def create_api_token(self, user_id: int, token_name: str, def create_api_token(self, user_id: int, token_name: str,
expires_hours: int = 8760) -> Dict[str, Any]: expires_hours: int = 8760) -> Dict[str, Any]:
...@@ -378,14 +386,14 @@ class DashboardAPI: ...@@ -378,14 +386,14 @@ class DashboardAPI:
result = auth_manager.create_api_token(user_id, token_name, expires_hours) result = auth_manager.create_api_token(user_id, token_name, expires_hours)
if result: if result:
token, token_record = result token, token_data = result
return { return {
"success": True, "success": True,
"token": token, "token": token,
"token_info": { "token_info": {
"id": token_record.id, "id": token_data['id'],
"name": token_record.token_name, "name": token_data['name'],
"expires_at": token_record.expires_at.isoformat() "expires_at": token_data['expires_at'].isoformat() if isinstance(token_data['expires_at'], datetime) else token_data['expires_at']
} }
} }
else: else:
...@@ -457,4 +465,101 @@ class DashboardAPI: ...@@ -457,4 +465,101 @@ class DashboardAPI:
except Exception as e: except Exception as e:
logger.error(f"Test message error: {e}") logger.error(f"Test message error: {e}")
return {"error": str(e)} return {"error": str(e)}
\ No newline at end of file
def upload_video(self, file_data, template: str) -> Dict[str, Any]:
"""Handle video upload"""
try:
import os
import uuid
from werkzeug.utils import secure_filename
# Create uploads directory if it doesn't exist
upload_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'uploads')
os.makedirs(upload_dir, exist_ok=True)
# Generate unique filename
filename = secure_filename(file_data.filename)
if not filename:
filename = str(uuid.uuid4()) + ".mp4"
# Add timestamp to filename to make it unique
name, ext = os.path.splitext(filename)
unique_filename = f"{name}_{int(time.time())}{ext}"
# Save file
file_path = os.path.join(upload_dir, unique_filename)
file_data.save(file_path)
# Return relative path for the Qt player
relative_path = os.path.join('uploads', unique_filename)
logger.info(f"Video uploaded: {relative_path}")
return {
"success": True,
"filename": relative_path,
"message": "Video uploaded successfully"
}
except Exception as e:
logger.error(f"Video upload error: {e}")
return {"error": str(e)}
def delete_video(self, filename: str) -> Dict[str, Any]:
"""Delete uploaded video"""
try:
import os
# Construct full path
file_path = os.path.join(os.path.dirname(__file__), '..', '..', filename)
# Check if file exists
if os.path.exists(file_path):
os.remove(file_path)
logger.info(f"Video deleted: {filename}")
return {"success": True, "message": "Video deleted successfully"}
else:
return {"error": "File not found"}
except Exception as e:
logger.error(f"Video deletion error: {e}")
return {"error": str(e)}
# Route functions for Flask
def get_config_section(section):
"""Get configuration section"""
try:
api = g.get('api')
if not api:
return jsonify({"error": "API not available"}), 500
result = api.get_configuration(section)
if "error" in result:
return jsonify(result), 500
else:
return jsonify(result)
except Exception as e:
logger.error(f"Route get_config_section error: {e}")
return jsonify({"error": str(e)}), 500
def update_config_section(section):
"""Update configuration section"""
try:
api = g.get('api')
if not api:
return jsonify({"error": "API not available"}), 500
data = request.get_json() or {}
result = api.update_configuration(section, data)
if "error" in result:
return jsonify(result), 500
else:
return jsonify(result)
except Exception as e:
logger.error(f"Route update_config_section error: {e}")
return jsonify({"error": str(e)}), 500
\ No newline at end of file
...@@ -108,7 +108,16 @@ class WebDashboard(ThreadedComponent): ...@@ -108,7 +108,16 @@ class WebDashboard(ThreadedComponent):
# User loader for Flask-Login # User loader for Flask-Login
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
return self.db_manager.get_user_by_id(int(user_id)) from .auth import AuthenticatedUser
user_model = self.db_manager.get_user_by_id(int(user_id))
if user_model:
return AuthenticatedUser(
user_id=user_model.id,
username=user_model.username,
email=user_model.email,
is_admin=user_model.is_admin
)
return None
# JWT error handlers # JWT error handlers
@jwt_manager.expired_token_loader @jwt_manager.expired_token_loader
......
...@@ -13,7 +13,7 @@ from flask_jwt_extended import create_access_token, decode_token ...@@ -13,7 +13,7 @@ from flask_jwt_extended import create_access_token, decode_token
import jwt import jwt
from ..database.manager import DatabaseManager from ..database.manager import DatabaseManager
from ..database.models import User, APIToken from ..database.models import UserModel as User, ApiTokenModel as APIToken
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -26,13 +26,26 @@ class AuthenticatedUser(UserMixin): ...@@ -26,13 +26,26 @@ class AuthenticatedUser(UserMixin):
self.username = username self.username = username
self.email = email self.email = email
self.is_admin = is_admin self.is_admin = is_admin
self.is_authenticated = True # Don't set Flask-Login properties - they are handled by UserMixin
self.is_active = True
self.is_anonymous = False
def get_id(self): def get_id(self):
return str(self.id) return str(self.id)
@property
def is_authenticated(self):
"""Override UserMixin property"""
return True
@property
def is_active(self):
"""Override UserMixin property"""
return True
@property
def is_anonymous(self):
"""Override UserMixin property"""
return False
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
return { return {
'id': self.id, 'id': self.id,
...@@ -98,8 +111,8 @@ class AuthManager: ...@@ -98,8 +111,8 @@ class AuthManager:
logger.error(f"Authentication error: {e}") logger.error(f"Authentication error: {e}")
return None return None
def create_user(self, username: str, email: str, password: str, def create_user(self, username: str, email: str, password: str,
is_admin: bool = False) -> Optional[User]: is_admin: bool = False) -> Optional[Dict[str, Any]]:
"""Create new user""" """Create new user"""
try: try:
# Check if user already exists # Check if user already exists
...@@ -123,9 +136,9 @@ class AuthManager: ...@@ -123,9 +136,9 @@ class AuthManager:
created_at=datetime.utcnow() created_at=datetime.utcnow()
) )
saved_user = self.db_manager.save_user(user) saved_user_data = self.db_manager.save_user(user)
logger.info(f"User created successfully: {username}") logger.info(f"User created successfully: {username}")
return saved_user return saved_user_data
except Exception as e: except Exception as e:
logger.error(f"User creation error: {e}") logger.error(f"User creation error: {e}")
...@@ -178,7 +191,7 @@ class AuthManager: ...@@ -178,7 +191,7 @@ class AuthManager:
# Store token in database # Store token in database
api_token = APIToken( api_token = APIToken(
user_id=user.id, user_id=user.id,
token_name=f"Web Token {datetime.utcnow().strftime('%Y-%m-%d %H:%M')}", name=f"Web Token {datetime.utcnow().strftime('%Y-%m-%d %H:%M')}",
token_hash=self._hash_token(token), token_hash=self._hash_token(token),
expires_at=datetime.utcnow() + timedelta(hours=expires_hours), expires_at=datetime.utcnow() + timedelta(hours=expires_hours),
created_at=datetime.utcnow() created_at=datetime.utcnow()
...@@ -229,27 +242,30 @@ class AuthManager: ...@@ -229,27 +242,30 @@ class AuthManager:
return None return None
def revoke_jwt_token(self, token: str) -> bool: def revoke_jwt_token(self, token: str) -> bool:
"""Revoke JWT token""" """Delete JWT token"""
try: try:
token_hash = self._hash_token(token) token_hash = self._hash_token(token)
api_token = self.db_manager.get_api_token_by_hash(token_hash) api_token = self.db_manager.get_api_token_by_hash(token_hash)
if api_token: if api_token:
api_token.revoked = True # Delete the token completely from database
api_token.updated_at = datetime.utcnow() success = self.db_manager.delete_api_token(api_token.id)
self.db_manager.save_api_token(api_token)
logger.info(f"JWT token revoked: {api_token.token_name}") if success:
return True logger.info(f"JWT token deleted: {api_token.name}")
return True
else:
logger.error(f"Failed to delete JWT token from database: {api_token.id}")
return False
return False return False
except Exception as e: except Exception as e:
logger.error(f"JWT token revocation error: {e}") logger.error(f"JWT token deletion error: {e}")
return False return False
def create_api_token(self, user_id: int, token_name: str, def create_api_token(self, user_id: int, token_name: str,
expires_hours: int = 8760) -> Optional[Tuple[str, APIToken]]: # 1 year default expires_hours: int = 8760) -> Optional[Tuple[str, Dict[str, Any]]]: # 1 year default
"""Create long-lived API token""" """Create long-lived API token"""
try: try:
user = self.db_manager.get_user_by_id(user_id) user = self.db_manager.get_user_by_id(user_id)
...@@ -259,20 +275,25 @@ class AuthManager: ...@@ -259,20 +275,25 @@ class AuthManager:
# Generate secure token # Generate secure token
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
token_hash = self._hash_token(token) token_hash = self._hash_token(token)
expires_at = datetime.utcnow() + timedelta(hours=expires_hours)
# Create API token record # Create API token record
api_token = APIToken( api_token = APIToken(
user_id=user.id, user_id=user.id,
token_name=token_name, name=token_name,
token_hash=token_hash, token_hash=token_hash,
expires_at=datetime.utcnow() + timedelta(hours=expires_hours), expires_at=expires_at,
created_at=datetime.utcnow() created_at=datetime.utcnow()
) )
saved_token = self.db_manager.save_api_token(api_token) token_data = self.db_manager.save_api_token(api_token)
logger.info(f"API token created: {token_name} for user {user.username}") if token_data:
return token, saved_token logger.info(f"API token created: {token_name} for user {user.username}")
return token, token_data
else:
logger.error(f"Failed to save API token to database")
return None
except Exception as e: except Exception as e:
logger.error(f"API token creation error: {e}") logger.error(f"API token creation error: {e}")
...@@ -307,7 +328,7 @@ class AuthManager: ...@@ -307,7 +328,7 @@ class AuthManager:
'user_id': user.id, 'user_id': user.id,
'username': user.username, 'username': user.username,
'is_admin': user.is_admin, 'is_admin': user.is_admin,
'token_name': api_token.token_name, 'token_name': api_token.name,
'token_id': api_token.id 'token_id': api_token.id
} }
...@@ -322,11 +343,11 @@ class AuthManager: ...@@ -322,11 +343,11 @@ class AuthManager:
return [ return [
{ {
'id': token.id, 'id': token.id,
'name': token.token_name, 'name': token.name,
'created_at': token.created_at.isoformat(), 'created_at': token.created_at.isoformat(),
'expires_at': token.expires_at.isoformat(), 'expires_at': token.expires_at.isoformat(),
'last_used': token.last_used.isoformat() if token.last_used else None, 'last_used': token.last_used_at.isoformat() if token.last_used_at else None,
'revoked': token.revoked 'revoked': not token.is_active
} }
for token in tokens for token in tokens
] ]
...@@ -335,22 +356,25 @@ class AuthManager: ...@@ -335,22 +356,25 @@ class AuthManager:
return [] return []
def revoke_api_token_by_id(self, token_id: int, user_id: int) -> bool: def revoke_api_token_by_id(self, token_id: int, user_id: int) -> bool:
"""Revoke API token by ID""" """Delete API token by ID"""
try: try:
api_token = self.db_manager.get_api_token_by_id(token_id) api_token = self.db_manager.get_api_token_by_id(token_id)
if api_token and api_token.user_id == user_id: if api_token and api_token.user_id == user_id:
api_token.revoked = True # Delete the token completely from database
api_token.updated_at = datetime.utcnow() success = self.db_manager.delete_api_token(token_id)
self.db_manager.save_api_token(api_token)
logger.info(f"API token revoked: {api_token.token_name}") if success:
return True logger.info(f"API token deleted: {api_token.name}")
return True
else:
logger.error(f"Failed to delete API token from database: {token_id}")
return False
return False return False
except Exception as e: except Exception as e:
logger.error(f"API token revocation error: {e}") logger.error(f"API token deletion error: {e}")
return False return False
def cleanup_expired_tokens(self): def cleanup_expired_tokens(self):
......
...@@ -47,8 +47,8 @@ def index(): ...@@ -47,8 +47,8 @@ def index():
@main_bp.route('/video') @main_bp.route('/video')
@login_required @login_required
def video_control(): def video_control_page():
"""Video control page""" """Video control page"""
try: try:
return render_template('dashboard/video.html', return render_template('dashboard/video.html',
...@@ -182,6 +182,20 @@ def login(): ...@@ -182,6 +182,20 @@ def login():
return render_template('auth/login.html') return render_template('auth/login.html')
return render_template('auth/login.html') return render_template('auth/login.html')
@main_bp.route('/video_test')
@login_required
def video_test():
"""Video upload test page"""
try:
return render_template('dashboard/video_test.html',
user=current_user,
page_title="Video Upload Test")
except Exception as e:
logger.error(f"Video test page error: {e}")
flash("Error loading video test page", "error")
return render_template('errors/500.html'), 500
@auth_bp.route('/logout') @auth_bp.route('/logout')
...@@ -276,7 +290,9 @@ def video_control(): ...@@ -276,7 +290,9 @@ def video_control():
if not action: if not action:
return jsonify({"error": "Action is required"}), 400 return jsonify({"error": "Action is required"}), 400
result = api_bp.api.control_video(action, **data) # Remove action from data to avoid duplicate argument error
control_data = {k: v for k, v in data.items() if k != 'action'}
result = api_bp.api.control_video(action, **control_data)
return jsonify(result) return jsonify(result)
except Exception as e: except Exception as e:
...@@ -329,6 +345,32 @@ def get_configuration(): ...@@ -329,6 +345,32 @@ def get_configuration():
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@api_bp.route('/config/<section>')
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def get_config_section(section):
"""Get configuration section"""
try:
config = api_bp.api.get_configuration(section)
return jsonify(config)
except Exception as e:
logger.error(f"API get config section error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/config/<section>', 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_config_section(section):
"""Update configuration section"""
try:
data = request.get_json() or {}
result = api_bp.api.update_configuration(section, data)
return jsonify(result)
except Exception as e:
logger.error(f"API update config section error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/config', methods=['POST']) @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_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 @api_bp.auth_manager.require_admin if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
...@@ -499,6 +541,48 @@ def send_test_message(): ...@@ -499,6 +541,48 @@ def send_test_message():
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
# Video upload and delete routes
@api_bp.route('/video/upload', methods=['POST'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def upload_video():
"""Upload video file"""
try:
if 'video' not in request.files:
return jsonify({"error": "No video file provided"}), 400
file = request.files['video']
if file.filename == '':
return jsonify({"error": "No file selected"}), 400
template = request.form.get('template', 'news_template')
result = api_bp.api.upload_video(file, template)
return jsonify(result)
except Exception as e:
logger.error(f"API video upload error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/video/delete', methods=['POST'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def delete_video():
"""Delete uploaded video"""
try:
data = request.get_json() or {}
filename = data.get('filename')
if not filename:
return jsonify({"error": "Filename is required"}), 400
result = api_bp.api.delete_video(filename)
return jsonify(result)
except Exception as e:
logger.error(f"API video delete error: {e}")
return jsonify({"error": str(e)}), 500
# Auth token endpoint for JWT creation # Auth token endpoint for JWT creation
@auth_bp.route('/token', methods=['POST']) @auth_bp.route('/token', methods=['POST'])
def create_auth_token(): def create_auth_token():
......
...@@ -5,16 +5,10 @@ ...@@ -5,16 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ page_title | default("Dashboard") }} - {{ app_name }}{% endblock %}</title> <title>{% block title %}{{ page_title | default("Dashboard") }} - {{ app_name }}{% endblock %}</title>
<!-- Offline-first CSS --> <!-- CSS from CDN -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap.min.css') }}"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/dashboard.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 %} {% block head %}{% endblock %}
</head> </head>
...@@ -39,19 +33,25 @@ ...@@ -39,19 +33,25 @@
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.video_control' %}active{% endif %}" <a class="nav-link {% if request.endpoint == 'main.video_control_page' %}active{% endif %}"
href="{{ url_for('main.video_control') }}"> href="{{ url_for('main.video_control_page') }}">
<i class="fas fa-video me-1"></i>Video Control <i class="fas fa-video me-1"></i>Video Control
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.templates' %}active{% endif %}" <a class="nav-link {% if request.endpoint == 'main.templates' %}active{% endif %}"
href="{{ url_for('main.templates') }}"> href="{{ url_for('main.templates') }}">
<i class="fas fa-layer-group me-1"></i>Templates <i class="fas fa-layer-group me-1"></i>Templates
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.api_tokens' %}active{% endif %}" <a class="nav-link {% if request.endpoint == 'main.video_test' %}active{% endif %}"
href="{{ url_for('main.video_test') }}">
<i class="fas fa-upload me-1"></i>Video Test
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.api_tokens' %}active{% endif %}"
href="{{ url_for('main.api_tokens') }}"> href="{{ url_for('main.api_tokens') }}">
<i class="fas fa-key me-1"></i>API Tokens <i class="fas fa-key me-1"></i>API Tokens
</a> </a>
...@@ -144,14 +144,10 @@ ...@@ -144,14 +144,10 @@
</div> </div>
{% endif %} {% endif %}
<!-- Offline-first JavaScript --> <!-- JavaScript from CDN and local -->
<script src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='js/dashboard.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 %} {% if current_user.is_authenticated %}
<script id="dashboard-config" type="application/json"> <script id="dashboard-config" type="application/json">
{ {
......
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<h1>API Configuration</h1>
<p>Configure and test connection to MbetterD daemon.</p>
<!-- API Status -->
<div class="card mb-4">
<div class="card-header">
<h5>API Connection Status</h5>
</div>
<div class="card-body">
<div class="d-flex align-items-center">
<div class="status-indicator me-3" id="api-status-indicator"></div>
<div>
<span id="api-status-text">Checking connection...</span>
<button class="btn btn-sm btn-outline-primary ms-3" id="refresh-status">Refresh</button>
</div>
</div>
</div>
</div>
<!-- API Configuration -->
<div class="card mb-4">
<div class="card-header">
<h5>API Configuration</h5>
</div>
<div class="card-body">
<form id="api-config-form">
<div class="mb-3">
<label for="api-host" class="form-label">Host</label>
<input type="text" class="form-control" id="api-host" placeholder="localhost" value="{{ config.api_host or 'localhost' }}">
</div>
<div class="mb-3">
<label for="api-port" class="form-label">Port</label>
<input type="number" class="form-control" id="api-port" placeholder="8080" value="{{ config.api_port or 8080 }}">
</div>
<div class="mb-3">
<label for="api-token" class="form-label">API Token</label>
<input type="password" class="form-control" id="api-token" placeholder="Enter API token">
</div>
<button type="submit" class="btn btn-primary">Save Configuration</button>
</form>
</div>
</div>
<!-- API Test -->
<div class="card">
<div class="card-header">
<h5>API Test</h5>
</div>
<div class="card-body">
<button class="btn btn-success" id="test-api-btn">Test API Connection</button>
<div class="mt-3" id="api-test-result" style="display: none;">
<h6>Test Result:</h6>
<pre id="api-test-response"></pre>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
// Update API status indicator
function updateApiStatus(isConnected) {
const indicator = document.getElementById('api-status-indicator');
const statusText = document.getElementById('api-status-text');
if (isConnected) {
indicator.className = 'status-indicator status-connected';
statusText.textContent = 'Connected to MbetterD daemon';
} else {
indicator.className = 'status-indicator status-disconnected';
statusText.textContent = 'Disconnected from MbetterD daemon';
}
}
// Check API status on page load
fetch('/api/status')
.then(response => response.json())
.then(data => {
updateApiStatus(data.connected);
})
.catch(error => {
console.error('Error checking API status:', error);
updateApiStatus(false);
});
// Refresh status button
document.getElementById('refresh-status').addEventListener('click', function() {
fetch('/api/status')
.then(response => response.json())
.then(data => {
updateApiStatus(data.connected);
})
.catch(error => {
console.error('Error checking API status:', error);
updateApiStatus(false);
});
});
// Save API configuration
document.getElementById('api-config-form').addEventListener('submit', function(e) {
e.preventDefault();
const config = {
api_host: document.getElementById('api-host').value,
api_port: parseInt(document.getElementById('api-port').value),
api_token: document.getElementById('api-token').value
};
fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(config)
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Configuration saved successfully');
} else {
alert('Failed to save configuration: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to save configuration');
});
});
// Test API connection
document.getElementById('test-api-btn').addEventListener('click', function() {
const resultDiv = document.getElementById('api-test-result');
const responsePre = document.getElementById('api-test-response');
resultDiv.style.display = 'block';
responsePre.textContent = 'Testing...';
fetch('/api/test')
.then(response => response.json())
.then(data => {
responsePre.textContent = JSON.stringify(data, null, 2);
})
.catch(error => {
responsePre.textContent = 'Error: ' + error.message;
});
});
</script>
{% endblock %}
\ No newline at end of file
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<h1>Configuration</h1>
<p>Manage application settings and preferences.</p>
<!-- General Settings -->
<div class="card mb-4">
<div class="card-header">
<h5>General Settings</h5>
</div>
<div class="card-body">
<form id="general-config-form">
<div class="mb-3">
<label for="app-name" class="form-label">Application Name</label>
<input type="text" class="form-control" id="app-name" value="{{ config.app_name or 'MbetterClient' }}">
</div>
<div class="mb-3">
<label for="log-level" class="form-label">Log Level</label>
<select class="form-select" id="log-level">
<option value="DEBUG" {% if config.log_level == 'DEBUG' %}selected{% endif %}>DEBUG</option>
<option value="INFO" {% if config.log_level == 'INFO' %}selected{% endif %}>INFO</option>
<option value="WARNING" {% if config.log_level == 'WARNING' %}selected{% endif %}>WARNING</option>
<option value="ERROR" {% if config.log_level == 'ERROR' %}selected{% endif %}>ERROR</option>
</select>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="enable-qt" {% if config.enable_qt %}checked{% endif %}>
<label class="form-check-label" for="enable-qt">
Enable Qt Video Player
</label>
</div>
<button type="submit" class="btn btn-primary">Save General Settings</button>
</form>
</div>
</div>
<!-- Video Settings -->
<div class="card mb-4">
<div class="card-header">
<h5>Video Settings</h5>
</div>
<div class="card-body">
<form id="video-config-form">
<div class="mb-3">
<label for="video-width" class="form-label">Video Width</label>
<input type="number" class="form-control" id="video-width" value="{{ config.video_width or 1920 }}">
</div>
<div class="mb-3">
<label for="video-height" class="form-label">Video Height</label>
<input type="number" class="form-control" id="video-height" value="{{ config.video_height or 1080 }}">
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="fullscreen" {% if config.fullscreen %}checked{% endif %}>
<label class="form-check-label" for="fullscreen">
Fullscreen Mode
</label>
</div>
<button type="submit" class="btn btn-primary">Save Video Settings</button>
</form>
</div>
</div>
<!-- Database Settings -->
<div class="card">
<div class="card-header">
<h5>Database Settings</h5>
</div>
<div class="card-body">
<form id="database-config-form">
<div class="mb-3">
<label for="db-path" class="form-label">Database Path</label>
<input type="text" class="form-control" id="db-path" value="{{ config.db_path or 'data/mbetterclient.db' }}">
</div>
<button type="submit" class="btn btn-primary">Save Database Settings</button>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
// Save general configuration
document.getElementById('general-config-form').addEventListener('submit', function(e) {
e.preventDefault();
const config = {
app_name: document.getElementById('app-name').value,
log_level: document.getElementById('log-level').value,
enable_qt: document.getElementById('enable-qt').checked
};
saveConfig('general', config);
});
// Save video configuration
document.getElementById('video-config-form').addEventListener('submit', function(e) {
e.preventDefault();
const config = {
video_width: parseInt(document.getElementById('video-width').value),
video_height: parseInt(document.getElementById('video-height').value),
fullscreen: document.getElementById('fullscreen').checked
};
saveConfig('video', config);
});
// Save database configuration
document.getElementById('database-config-form').addEventListener('submit', function(e) {
e.preventDefault();
const config = {
db_path: document.getElementById('db-path').value
};
saveConfig('database', config);
});
// Generic config save function
function saveConfig(section, config) {
fetch('/api/config/' + section, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(config)
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(section.charAt(0).toUpperCase() + section.slice(1) + ' configuration saved successfully');
} else {
alert('Failed to save ' + section + ' configuration: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to save ' + section + ' configuration');
});
}
</script>
{% endblock %}
\ No newline at end of file
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<h1>Application Logs</h1>
<p>View and monitor application logs.</p>
<!-- Log Filters -->
<div class="card mb-4">
<div class="card-header">
<h5>Log Filters</h5>
</div>
<div class="card-body">
<form id="log-filter-form" class="row g-3">
<div class="col-md-3">
<label for="log-level" class="form-label">Log Level</label>
<select class="form-select" id="log-level">
<option value="">All Levels</option>
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
<option value="CRITICAL">CRITICAL</option>
</select>
</div>
<div class="col-md-3">
<label for="log-component" class="form-label">Component</label>
<select class="form-select" id="log-component">
<option value="">All Components</option>
<option value="core">Core</option>
<option value="web_dashboard">Web Dashboard</option>
<option value="qt_player">Qt Player</option>
<option value="api_client">API Client</option>
<option value="database">Database</option>
</select>
</div>
<div class="col-md-3">
<label for="log-limit" class="form-label">Limit</label>
<select class="form-select" id="log-limit">
<option value="50">50 entries</option>
<option value="100" selected>100 entries</option>
<option value="500">500 entries</option>
<option value="1000">1000 entries</option>
</select>
</div>
<div class="col-md-3 d-flex align-items-end">
<button type="submit" class="btn btn-primary me-2">Filter</button>
<button type="button" class="btn btn-secondary" id="refresh-logs">Refresh</button>
</div>
</form>
</div>
</div>
<!-- Log Entries -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5>Log Entries</h5>
<button class="btn btn-danger btn-sm" id="clear-logs">
<i class="fas fa-trash"></i> Clear Logs
</button>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Timestamp</th>
<th>Level</th>
<th>Component</th>
<th>Message</th>
</tr>
</thead>
<tbody id="logs-table-body">
<!-- Log rows will be populated by JavaScript -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
// Load logs on page load
function loadLogs() {
const level = document.getElementById('log-level').value;
const component = document.getElementById('log-component').value;
const limit = document.getElementById('log-limit').value;
let url = '/api/logs?limit=' + limit;
if (level) url += '&level=' + level;
if (component) url += '&component=' + component;
fetch(url)
.then(response => response.json())
.then(data => {
const tbody = document.getElementById('logs-table-body');
tbody.innerHTML = '';
data.forEach(log => {
const row = document.createElement('tr');
const timestamp = new Date(log.timestamp).toLocaleString();
// Add level-specific class for coloring
let levelClass = '';
switch (log.level) {
case 'ERROR':
case 'CRITICAL':
levelClass = 'table-danger';
break;
case 'WARNING':
levelClass = 'table-warning';
break;
case 'INFO':
levelClass = 'table-info';
break;
default:
levelClass = '';
}
row.className = levelClass;
row.innerHTML = `
<td>${timestamp}</td>
<td>${log.level}</td>
<td>${log.component}</td>
<td>${log.message}</td>
`;
tbody.appendChild(row);
});
})
.catch(error => {
console.error('Error loading logs:', error);
});
}
// Filter logs
document.getElementById('log-filter-form').addEventListener('submit', function(e) {
e.preventDefault();
loadLogs();
});
// Refresh logs
document.getElementById('refresh-logs').addEventListener('click', function() {
loadLogs();
});
// Clear logs
document.getElementById('clear-logs').addEventListener('click', function() {
if (confirm('Are you sure you want to clear all logs?')) {
// In a real implementation, this would clear the logs
alert('Clear logs functionality would be implemented here');
}
});
// Load logs when page loads
document.addEventListener('DOMContentLoaded', function() {
loadLogs();
});
</script>
{% endblock %}
\ No newline at end of file
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<h1>Template Management</h1>
<p>Manage video overlay templates and create custom designs.</p>
<!-- Template List -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5>Available Templates</h5>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createTemplateModal">
<i class="fas fa-plus"></i> Create Template
</button>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>Category</th>
<th>Author</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="templates-table-body">
<!-- Template rows will be populated by JavaScript -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Template Preview -->
<div class="card">
<div class="card-header">
<h5>Template Preview</h5>
</div>
<div class="card-body">
<div id="template-preview" class="template-preview-container">
<p>Select a template to preview</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Create Template Modal -->
<div class="modal fade" id="createTemplateModal" tabindex="-1" aria-labelledby="createTemplateModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="createTemplateModalLabel">Create New Template</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="create-template-form">
<div class="mb-3">
<label for="template-name" class="form-label">Template Name</label>
<input type="text" class="form-control" id="template-name" required>
</div>
<div class="mb-3">
<label for="template-category" class="form-label">Category</label>
<select class="form-select" id="template-category">
<option value="news">News</option>
<option value="sports">Sports</option>
<option value="entertainment">Entertainment</option>
<option value="custom">Custom</option>
</select>
</div>
<div class="mb-3">
<label for="template-description" class="form-label">Description</label>
<textarea class="form-control" id="template-description" rows="3"></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-primary" id="create-template-btn">Create Template</button>
</div>
</div>
</div>
</div>
<script>
// Load templates on page load
function loadTemplates() {
fetch('/api/templates')
.then(response => response.json())
.then(data => {
const tbody = document.getElementById('templates-table-body');
tbody.innerHTML = '';
data.forEach(template => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${template.name}</td>
<td>${template.category}</td>
<td>${template.author || 'System'}</td>
<td>
<button class="btn btn-sm btn-primary edit-template" data-id="${template.id}">Edit</button>
<button class="btn btn-sm btn-info preview-template" data-id="${template.id}">Preview</button>
<button class="btn btn-sm btn-danger delete-template" data-id="${template.id}">Delete</button>
</td>
`;
tbody.appendChild(row);
});
// Add event listeners to action buttons
document.querySelectorAll('.edit-template').forEach(btn => {
btn.addEventListener('click', function() {
const templateId = this.getAttribute('data-id');
editTemplate(templateId);
});
});
document.querySelectorAll('.preview-template').forEach(btn => {
btn.addEventListener('click', function() {
const templateId = this.getAttribute('data-id');
previewTemplate(templateId);
});
});
document.querySelectorAll('.delete-template').forEach(btn => {
btn.addEventListener('click', function() {
const templateId = this.getAttribute('data-id');
deleteTemplate(templateId);
});
});
})
.catch(error => {
console.error('Error loading templates:', error);
});
}
// Preview template
function previewTemplate(templateId) {
const previewContainer = document.getElementById('template-preview');
previewContainer.innerHTML = `<p>Loading preview for template ${templateId}...</p>`;
// In a real implementation, this would show a visual preview of the template
}
// Edit template
function editTemplate(templateId) {
alert(`Edit template ${templateId} - This would open the template editor`);
}
// Delete template
function deleteTemplate(templateId) {
if (confirm('Are you sure you want to delete this template?')) {
// In a real implementation, this would delete the template
alert(`Delete template ${templateId} - This would delete the template from the database`);
}
}
// Create template
document.getElementById('create-template-btn').addEventListener('click', function() {
const name = document.getElementById('template-name').value;
const category = document.getElementById('template-category').value;
const description = document.getElementById('template-description').value;
if (!name) {
alert('Template name is required');
return;
}
// In a real implementation, this would create the template
alert(`Create template: ${name} (${category}) - This would save the template to the database`);
// Close modal and refresh template list
const modal = bootstrap.Modal.getInstance(document.getElementById('createTemplateModal'));
modal.hide();
loadTemplates();
});
// Load templates when page loads
document.addEventListener('DOMContentLoaded', function() {
loadTemplates();
});
</script>
{% endblock %}
\ No newline at end of file
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<h1>API Tokens</h1>
<p>Manage API tokens for programmatic access.</p>
<!-- Create Token -->
<div class="card mb-4">
<div class="card-header">
<h5>Create New Token</h5>
</div>
<div class="card-body">
<form id="create-token-form">
<div class="mb-3">
<label for="token-name" class="form-label">Token Name</label>
<input type="text" class="form-control" id="token-name" placeholder="e.g., Mobile App, Script" required>
</div>
<div class="mb-3">
<label for="token-expiry" class="form-label">Expires In</label>
<select class="form-select" id="token-expiry">
<option value="1">1 hour</option>
<option value="24">1 day</option>
<option value="168">1 week</option>
<option value="720">1 month</option>
<option value="8760" selected>1 year</option>
<option value="87600">10 years</option>
</select>
</div>
<button type="submit" class="btn btn-primary">Create Token</button>
</form>
</div>
</div>
<!-- Active Tokens -->
<div class="card">
<div class="card-header">
<h5>Active Tokens</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>Created</th>
<th>Expires</th>
<th>Token</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="tokens-table-body">
<!-- Token rows will be populated by JavaScript -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
// Load tokens on page load
function loadTokens() {
fetch('/api/tokens')
.then(response => response.json())
.then(data => {
const tbody = document.getElementById('tokens-table-body');
tbody.innerHTML = '';
data.forEach(token => {
const row = document.createElement('tr');
const createdDate = new Date(token.created_at).toLocaleString();
const expiresDate = new Date(token.expires_at).toLocaleString();
const isExpired = new Date(token.expires_at) < new Date();
row.innerHTML = `
<td>${token.name}</td>
<td>${createdDate}</td>
<td>${expiresDate} ${isExpired ? '<span class="badge bg-danger">Expired</span>' : ''}</td>
<td>
<code>${token.token_preview}</code>
<button class="btn btn-sm btn-outline-primary copy-token" data-token="${token.token}">
<i class="fas fa-copy"></i>
</button>
</td>
<td>
<button class="btn btn-sm btn-danger revoke-token" data-id="${token.id}">Revoke</button>
</td>
`;
tbody.appendChild(row);
});
// Add event listeners to action buttons
document.querySelectorAll('.copy-token').forEach(btn => {
btn.addEventListener('click', function() {
const token = this.getAttribute('data-token');
copyToClipboard(token);
});
});
document.querySelectorAll('.revoke-token').forEach(btn => {
btn.addEventListener('click', function() {
const tokenId = this.getAttribute('data-id');
revokeToken(tokenId);
});
});
})
.catch(error => {
console.error('Error loading tokens:', error);
});
}
// Copy token to clipboard
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
alert('Token copied to clipboard');
}).catch(err => {
console.error('Failed to copy token: ', err);
});
}
// Revoke token
function revokeToken(tokenId) {
if (confirm('Are you sure you want to revoke this token?')) {
fetch(`/api/tokens/${tokenId}`, {
method: 'DELETE'
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Token revoked successfully');
loadTokens(); // Refresh token list
} else {
alert('Failed to revoke token: ' + data.error);
}
})
.catch(error => {
console.error('Error revoking token:', error);
alert('Failed to revoke token');
});
}
}
// Create token
document.getElementById('create-token-form').addEventListener('submit', function(e) {
e.preventDefault();
const name = document.getElementById('token-name').value;
const expiresHours = parseInt(document.getElementById('token-expiry').value);
if (!name) {
alert('Token name is required');
return;
}
fetch('/api/tokens', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: name,
expires_hours: expiresHours
})
})
.then(response => response.json())
.then(data => {
if (data.success && data.token) {
// Show the token in a modal or alert
const tokenValue = data.token;
const tokenInfo = data.token_info;
// Create a modal to show the token
const tokenModal = `
<div class="modal fade" id="newTokenModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">New API Token Created</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle"></i>
<strong>Important:</strong> Copy this token now. You won't be able to see it again!
</div>
<div class="mb-3">
<label class="form-label">Token Name:</label>
<p><strong>${tokenInfo.name}</strong></p>
</div>
<div class="mb-3">
<label class="form-label">Your API Token:</label>
<div class="input-group">
<input type="text" class="form-control font-monospace" id="new-token-value" value="${tokenValue}" readonly>
<button class="btn btn-outline-primary" onclick="copyNewToken()">
<i class="fas fa-copy"></i> Copy
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label">Expires:</label>
<p>${new Date(tokenInfo.expires_at).toLocaleString()}</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">I've copied the token</button>
</div>
</div>
</div>
</div>
`;
// Add modal to page
document.body.insertAdjacentHTML('beforeend', tokenModal);
// Show modal
const modal = new bootstrap.Modal(document.getElementById('newTokenModal'));
modal.show();
// Clean up modal when closed
document.getElementById('newTokenModal').addEventListener('hidden.bs.modal', function () {
this.remove();
loadTokens(); // Refresh token list
});
// Reset form
document.getElementById('create-token-form').reset();
} else {
alert('Failed to create token: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
console.error('Error creating token:', error);
alert('Failed to create token');
});
});
// Copy new token function
function copyNewToken() {
const tokenInput = document.getElementById('new-token-value');
tokenInput.select();
document.execCommand('copy');
// Update button text briefly
const btn = event.target.closest('button');
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
setTimeout(() => {
btn.innerHTML = originalText;
}, 2000);
}
// Load tokens when page loads
document.addEventListener('DOMContentLoaded', function() {
loadTokens();
});
</script>
{% endblock %}
\ No newline at end of file
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<h1>User Management</h1>
<p>Manage user accounts and permissions.</p>
<!-- User List -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5>Users</h5>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createUserModal">
<i class="fas fa-plus"></i> Create User
</button>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Last Login</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="users-table-body">
<!-- User rows will be populated by JavaScript -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Create User Modal -->
<div class="modal fade" id="createUserModal" tabindex="-1" aria-labelledby="createUserModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="createUserModalLabel">Create New User</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="create-user-form">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" required>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" required>
</div>
<div class="mb-3">
<label for="confirm-password" class="form-label">Confirm Password</label>
<input type="password" class="form-control" id="confirm-password" required>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="is-admin">
<label class="form-check-label" for="is-admin">
Administrator
</label>
</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="create-user-btn">Create User</button>
</div>
</div>
</div>
</div>
<script>
// Load users on page load
function loadUsers() {
fetch('/api/users')
.then(response => response.json())
.then(data => {
const tbody = document.getElementById('users-table-body');
tbody.innerHTML = '';
data.forEach(user => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${user.username}</td>
<td>${user.email}</td>
<td>${user.is_admin ? 'Administrator' : 'User'}</td>
<td>${user.last_login || 'Never'}</td>
<td>
<button class="btn btn-sm btn-primary edit-user" data-id="${user.id}">Edit</button>
<button class="btn btn-sm btn-danger delete-user" data-id="${user.id}">Delete</button>
</td>
`;
tbody.appendChild(row);
});
// Add event listeners to action buttons
document.querySelectorAll('.edit-user').forEach(btn => {
btn.addEventListener('click', function() {
const userId = this.getAttribute('data-id');
editUser(userId);
});
});
document.querySelectorAll('.delete-user').forEach(btn => {
btn.addEventListener('click', function() {
const userId = this.getAttribute('data-id');
deleteUser(userId);
});
});
})
.catch(error => {
console.error('Error loading users:', error);
});
}
// Edit user
function editUser(userId) {
alert(`Edit user ${userId} - This would open the user editor`);
}
// Delete user
function deleteUser(userId) {
if (confirm('Are you sure you want to delete this user?')) {
// In a real implementation, this would delete the user
alert(`Delete user ${userId} - This would delete the user from the database`);
}
}
// Create user
document.getElementById('create-user-btn').addEventListener('click', function() {
const username = document.getElementById('username').value;
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirm-password').value;
const isAdmin = document.getElementById('is-admin').checked;
if (!username || !email || !password || !confirmPassword) {
alert('All fields are required');
return;
}
if (password !== confirmPassword) {
alert('Passwords do not match');
return;
}
// In a real implementation, this would create the user
alert(`Create user: ${username} (${email}) - This would save the user to the database`);
// Close modal and refresh user list
const modal = bootstrap.Modal.getInstance(document.getElementById('createUserModal'));
modal.hide();
loadUsers();
});
// Load users when page loads
document.addEventListener('DOMContentLoaded', function() {
loadUsers();
});
</script>
{% endblock %}
\ No newline at end of file
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<h1>Video Control</h1>
<p>Manage video playback and overlay content.</p>
<!-- Video Player -->
<div class="card mb-4">
<div class="card-header">
<h5>Video Player</h5>
</div>
<div class="card-body">
<div id="video-player" class="video-container">
<div class="video-placeholder">
<p>Video player will appear here</p>
</div>
</div>
<div class="video-controls mt-3">
<button class="btn btn-primary" id="play-btn">Play</button>
<button class="btn btn-secondary" id="pause-btn">Pause</button>
<button class="btn btn-danger" id="stop-btn">Stop</button>
</div>
</div>
</div>
<!-- Overlay Controls -->
<div class="card">
<div class="card-header">
<h5>Overlay Controls</h5>
</div>
<div class="card-body">
<form id="overlay-form">
<div class="mb-3">
<label for="template-select" class="form-label">Template</label>
<select class="form-select" id="template-select">
<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 for="overlay-text" class="form-label">Overlay Text</label>
<input type="text" class="form-control" id="overlay-text" placeholder="Enter overlay text">
</div>
<button type="submit" class="btn btn-success">Update Overlay</button>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
// Video control functions
document.getElementById('play-btn').addEventListener('click', function() {
fetch('/api/video/control', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({action: 'play'})
})
.then(response => response.json())
.then(data => {
console.log('Play response:', data);
})
.catch(error => {
console.error('Error:', error);
});
});
document.getElementById('pause-btn').addEventListener('click', function() {
fetch('/api/video/control', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({action: 'pause'})
})
.then(response => response.json())
.then(data => {
console.log('Pause response:', data);
})
.catch(error => {
console.error('Error:', error);
});
});
document.getElementById('stop-btn').addEventListener('click', function() {
fetch('/api/video/control', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({action: 'stop'})
})
.then(response => response.json())
.then(data => {
console.log('Stop response:', data);
})
.catch(error => {
console.error('Error:', error);
});
});
// Overlay update function
document.getElementById('overlay-form').addEventListener('submit', function(e) {
e.preventDefault();
const template = document.getElementById('template-select').value;
const text = document.getElementById('overlay-text').value;
fetch('/api/overlay/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
template: template,
data: {text: text}
})
})
.then(response => response.json())
.then(data => {
console.log('Overlay update response:', data);
alert('Overlay updated successfully');
})
.catch(error => {
console.error('Error:', error);
alert('Failed to update overlay');
});
});
</script>
{% endblock %}
\ 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-file-video me-2"></i>Video Upload Test
</h1>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-upload me-2"></i>Upload Video
</h5>
</div>
<div class="card-body">
<form id="upload-form" enctype="multipart/form-data">
<div class="mb-3">
<label for="video-file" class="form-label">Select Video File</label>
<input class="form-control" type="file" id="video-file" accept="video/*">
<div class="form-text">Supported formats: MP4, AVI, MOV, MKV</div>
</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>
<button type="submit" class="btn btn-primary" id="upload-btn">
<i class="fas fa-upload me-1"></i>Upload Video
</button>
</form>
<div class="mt-4" id="upload-progress-container" style="display: none;">
<div class="progress">
<div class="progress-bar" role="progressbar" id="upload-progress" style="width: 0%;">0%</div>
</div>
<div class="mt-2 text-center" id="upload-status">Uploading...</div>
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-list me-2"></i>Uploaded Videos
</h5>
</div>
<div class="card-body">
<div id="uploaded-videos-list">
<p class="text-muted text-center">No videos uploaded yet</p>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-info-circle me-2"></i>Instructions
</h5>
</div>
<div class="card-body">
<ol>
<li>Select a video file to upload</li>
<li>Choose an overlay template</li>
<li>Click "Upload Video" to start upload</li>
<li>After upload, click "Play" to view the video</li>
<li>Use "Delete" to remove uploaded videos</li>
</ol>
<div class="alert alert-info">
<i class="fas fa-lightbulb me-2"></i>
Videos are stored temporarily and will be deleted when the application restarts.
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-video me-2"></i>Player Status
</h5>
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<span>Status:</span>
<span id="player-status" class="badge bg-secondary">Unknown</span>
</div>
<div class="d-flex justify-content-between align-items-center mt-2">
<span>Current Video:</span>
<span id="current-video" class="text-muted">None</span>
</div>
<button class="btn btn-sm btn-outline-primary mt-3 w-100" id="refresh-status">
<i class="fas fa-sync me-1"></i>Refresh Status
</button>
</div>
</div>
</div>
</div>
<!-- Video Player Modal -->
<div class="modal fade" id="videoPlayerModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Video Player</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="video-player-container" style="position: relative; width: 100%; height: 0; padding-bottom: 56.25%;">
<video id="video-player" controls style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
Your browser does not support the video tag.
</video>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Elements
const uploadForm = document.getElementById('upload-form');
const videoFileInput = document.getElementById('video-file');
const videoTemplateSelect = document.getElementById('video-template');
const uploadBtn = document.getElementById('upload-btn');
const uploadProgressContainer = document.getElementById('upload-progress-container');
const uploadProgress = document.getElementById('upload-progress');
const uploadStatus = document.getElementById('upload-status');
const uploadedVideosList = document.getElementById('uploaded-videos-list');
const playerStatus = document.getElementById('player-status');
const currentVideo = document.getElementById('current-video');
const refreshStatusBtn = document.getElementById('refresh-status');
const videoPlayer = document.getElementById('video-player');
// Store uploaded videos
let uploadedVideos = [];
// Handle form submission
uploadForm.addEventListener('submit', function(e) {
e.preventDefault();
const file = videoFileInput.files[0];
if (!file) {
alert('Please select a video file');
return;
}
// Check file size (limit to 100MB for demo)
if (file.size > 100 * 1024 * 1024) {
alert('File size exceeds 100MB limit');
return;
}
uploadVideo(file);
});
// Upload video function
function uploadVideo(file) {
// Show progress
uploadProgressContainer.style.display = 'block';
uploadProgress.style.width = '0%';
uploadProgress.textContent = '0%';
uploadStatus.textContent = 'Uploading...';
uploadBtn.disabled = true;
// Create FormData
const formData = new FormData();
formData.append('video', file);
formData.append('template', videoTemplateSelect.value);
// Create XMLHttpRequest for progress tracking
const xhr = new XMLHttpRequest();
// Progress tracking
xhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
const percentComplete = Math.round((e.loaded / e.total) * 100);
uploadProgress.style.width = percentComplete + '%';
uploadProgress.textContent = percentComplete + '%';
}
});
// Handle response
xhr.addEventListener('load', function() {
uploadBtn.disabled = false;
if (xhr.status === 200) {
try {
const response = JSON.parse(xhr.responseText);
if (response.success) {
uploadStatus.textContent = 'Upload complete!';
uploadStatus.className = 'mt-2 text-center text-success';
// Add to uploaded videos list
addUploadedVideo(response.filename, file.name, videoTemplateSelect.value);
// Reset form after delay
setTimeout(() => {
uploadProgressContainer.style.display = 'none';
uploadForm.reset();
}, 2000);
} else {
uploadStatus.textContent = 'Upload failed: ' + (response.error || 'Unknown error');
uploadStatus.className = 'mt-2 text-center text-danger';
}
} catch (e) {
uploadStatus.textContent = 'Upload failed: Invalid response';
uploadStatus.className = 'mt-2 text-center text-danger';
}
} else {
uploadStatus.textContent = 'Upload failed: Server error';
uploadStatus.className = 'mt-2 text-center text-danger';
}
});
// Handle errors
xhr.addEventListener('error', function() {
uploadBtn.disabled = false;
uploadStatus.textContent = 'Upload failed: Network error';
uploadStatus.className = 'mt-2 text-center text-danger';
uploadProgressContainer.style.display = 'none';
});
// Send request
xhr.open('POST', '/api/video/upload');
xhr.send(formData);
}
// Add uploaded video to list
function addUploadedVideo(filename, displayName, template) {
const videoId = 'video-' + Date.now();
const videoItem = document.createElement('div');
videoItem.className = 'd-flex justify-content-between align-items-center mb-3 p-3 border rounded';
videoItem.id = videoId;
videoItem.innerHTML = `
<div>
<div class="fw-bold">${displayName}</div>
<small class="text-muted">Template: ${template}</small>
</div>
<div>
<button class="btn btn-sm btn-success play-btn me-2" data-filename="${filename}" data-template="${template}">
<i class="fas fa-play me-1"></i>Play
</button>
<button class="btn btn-sm btn-danger delete-btn" data-id="${videoId}" data-filename="${filename}">
<i class="fas fa-trash me-1"></i>Delete
</button>
</div>
`;
// Add to beginning of list
if (uploadedVideosList.querySelector('.text-muted')) {
uploadedVideosList.innerHTML = '';
}
uploadedVideosList.prepend(videoItem);
// Add event listeners
videoItem.querySelector('.play-btn').addEventListener('click', playVideo);
videoItem.querySelector('.delete-btn').addEventListener('click', deleteVideo);
// Store in array
uploadedVideos.push({
id: videoId,
filename: filename,
displayName: displayName,
template: template
});
}
// Play video function
function playVideo(e) {
const filename = e.target.closest('.play-btn').dataset.filename;
const template = e.target.closest('.play-btn').dataset.template;
// Send play command to Qt player
fetch('/api/video/control', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'play',
file_path: filename,
template: template
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Show player modal
new bootstrap.Modal(document.getElementById('videoPlayerModal')).show();
// Update player status
playerStatus.className = 'badge bg-success';
playerStatus.textContent = 'Playing';
currentVideo.textContent = filename.split('/').pop();
} else {
alert('Failed to play video: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
alert('Error: ' + error.message);
});
}
// Delete video function
function deleteVideo(e) {
const videoId = e.target.closest('.delete-btn').dataset.id;
const filename = e.target.closest('.delete-btn').dataset.filename;
if (!confirm('Are you sure you want to delete this video?')) {
return;
}
// Send delete request
fetch('/api/video/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
filename: filename
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Remove from list
const videoItem = document.getElementById(videoId);
if (videoItem) {
videoItem.remove();
}
// Remove from array
uploadedVideos = uploadedVideos.filter(video => video.id !== videoId);
// Show message if no videos left
if (uploadedVideos.length === 0) {
uploadedVideosList.innerHTML = '<p class="text-muted text-center">No videos uploaded yet</p>';
}
alert('Video deleted successfully');
} else {
alert('Failed to delete video: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
alert('Error: ' + error.message);
});
}
// Refresh player status
refreshStatusBtn.addEventListener('click', function() {
fetch('/api/video/status')
.then(response => response.json())
.then(data => {
if (data.player_status) {
playerStatus.textContent = data.player_status.charAt(0).toUpperCase() + data.player_status.slice(1);
playerStatus.className = 'badge ' +
(data.player_status === 'playing' ? 'bg-success' :
data.player_status === 'paused' ? 'bg-warning' : 'bg-secondary');
if (data.current_file) {
currentVideo.textContent = data.current_file.split('/').pop();
} else {
currentVideo.textContent = 'None';
}
}
})
.catch(error => {
console.error('Failed to get player status:', error);
});
});
// Initialize player status
refreshStatusBtn.click();
});
</script>
{% endblock %}
\ No newline at end of file
{% extends "base.html" %}
{% block title %}Internal Server Error{% endblock %}
{% block content %}
<div class="error-container">
<h1>Internal Server Error</h1>
<p>An unexpected error occurred. Please try again later.</p>
<a href="{{ url_for('main.index') }}" class="btn btn-primary">Return to Dashboard</a>
</div>
{% endblock %}
\ No newline at end of file
# MbetterClient v1.0.0
Cross-platform multimedia client application
## 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**: Linux 5.16.0-1-amd64
- **Architecture**: x86_64
- **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%\MbetterClient`
- **macOS**: `~/Library/Application Support/MbetterClient`
- **Linux**: `~/.config/MbetterClient`
## 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: 1.0.0
- Build Date: zeiss
- Platform: Linux-5.16.0-1-amd64-x86_64-with-glibc2.41
# Core dependencies # Core dependencies
PyQt5==5.15.10 Flask>=2.3.0
Flask==3.0.3 Flask-Login>=0.6.0
Flask-Login==0.6.3 Flask-WTF>=1.1.0
Flask-WTF==1.2.1 Flask-JWT-Extended>=4.4.0
Flask-JWT-Extended==4.6.0 requests>=2.28.0
SQLAlchemy==2.0.25
requests==2.31.0 # GUI - PyQt5
PyQt5>=5.15.0
# Database # Database
sqlite3 SQLAlchemy>=2.0.0
# GUI and multimedia # Security and authentication
opencv-python==4.9.0.80 cryptography>=3.4.8
Pillow==10.2.0 bcrypt>=4.0.0
# Web interface # Web framework utilities
Werkzeug==3.0.1 Werkzeug>=2.3.0
Jinja2==3.1.3 Jinja2>=3.1.0
WTForms==3.1.1 WTForms>=3.0.0
MarkupSafe==2.1.4
itsdangerous==2.1.2
# Security and authentication # Configuration and environment
cryptography==42.0.4 python-dotenv>=0.19.0
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 # Utilities and system
loguru==0.7.2 psutil>=5.8.0
click>=8.0.0
# Video and image processing
opencv-python>=4.5.0
Pillow>=9.0.0
# Testing (optional) # Logging
pytest==8.0.0 loguru>=0.7.0
pytest-qt==4.3.1
# Video processing # Building and packaging
ffmpeg-python==0.2.0 PyInstaller>=5.0.0
\ No newline at end of file \ 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