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
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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.0] - 2025-08-19
## [Unreleased]
## [1.0.0] - 2025-01-19
### Added
- Enhanced token creation workflow with professional modal dialogs
- 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
- Initial release of MbetterClient
- PyQt5 video player with hardware acceleration support
- Dynamic overlay system with three built-in templates:
- News template with scrolling ticker
- Sports template with live scores
- Simple template for basic text overlays
- Flask-based web dashboard with modern UI
- JWT authentication with role-based access control
- User management system with admin and regular user roles
- API token management for secure API access
- PyQt video player with overlay support
- Flask web dashboard with authentication
- REST API client with configurable endpoints
- Automatic retry logic and error handling for API requests
- Built-in response handlers for news and sports APIs
- SQLite database with automatic schema migrations
- Multi-threaded architecture with Queue-based message passing
- Cross-platform executable generation with PyInstaller
- Offline-first design with local asset fallbacks
- Comprehensive configuration management
- Real-time system status monitoring
- Application logging with rotation and filtering
- Command-line interface with multiple options
- Web-based configuration interface
- Template-based video overlays with real-time data
- Fullscreen video playback support
- Video control via web dashboard and keyboard shortcuts
### Security
- Password hashing with salt
- Secure session management
- CSRF protection for web forms
- JWT token expiration and refresh
- API rate limiting and validation
- Secure configuration file handling
### Performance
- Hardware-accelerated video playback
- Efficient message bus with priority handling
- Lazy loading of video codecs
- Optimized database queries with connection pooling
- Memory-efficient overlay rendering
- Compressed static assets for web dashboard
### Documentation
- Complete user documentation
- API reference with examples
- Development guide with contribution guidelines
- Troubleshooting guide
- Build and deployment instructions
- Code examples and tutorials
### Supported Platforms
- Windows 10 and later
- macOS 10.14 and later
- Linux (Ubuntu 18.04+, CentOS 7+, Debian 10+)
### Dependencies
- Python 3.8+
- PyQt5 5.15.10
- Flask 3.0.3
- SQLAlchemy 2.0.25
- Requests 2.31.0
- PyInstaller 6.3.0
- And other dependencies listed in requirements.txt
### Known Issues
- Large video files may cause memory issues on systems with < 1GB RAM
- Some video codecs require additional system libraries on Linux
- Windows Defender may flag executables built with PyInstaller
### Migration Notes
- This is the initial release, no migration required
- Default admin credentials must be changed on first login
- Configuration files are created automatically on first run
## [Future Releases]
### Planned Features
- Plugin system for custom extensions
- Advanced video effects and transitions
- WebRTC streaming support
- Mobile device remote control
- Multi-monitor support with independent overlays
- Cloud configuration synchronization
- Advanced analytics and reporting
- Integration with popular streaming platforms
- Real-time collaboration features
- Advanced template editor with drag-and-drop interface
### Potential Breaking Changes
- Configuration file format may change in v2.0
- Message bus API may be redesigned for better performance
- Database schema updates may require manual migration
- Web dashboard API may be versioned for backward compatibility
---
## Version History
| Version | Release Date | Notes |
|---------|-------------|--------|
| 1.0.0 | 2025-01-19 | Initial release |
## Support Policy
- **Current Version (1.x)**: Full support with bug fixes and security updates
- **Previous Versions**: Security updates only for 6 months after new major release
- **LTS Versions**: Will be designated for enterprise users starting with v2.0
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute to this project.
## License
This project is licensed under the MIT License - see [LICENSE](LICENSE) for details.
\ No newline at end of file
- Multi-threaded architecture with message bus
- SQLite database with versioning system
- PyInstaller build configuration
- Cross-platform support (Windows, Linux, macOS)
- JWT and long-lived API token management
- User management system
- Configuration management interface
- Video upload and playback testing
### Features
- Fullscreen and windowed video playback modes
- Customizable video overlay templates
- Web-based administration interface
- API token creation and management
- User authentication and authorization
- Database migrations system
- Message-based inter-thread communication
- Comprehensive logging system
- Configuration persistence
\ No newline at end of file
......@@ -11,6 +11,22 @@ A cross-platform multimedia client application with video playback, web dashboar
- **Offline Capability**: Works seamlessly without internet connectivity
- **Cross-Platform**: Supports Windows, Linux, and macOS
- **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
......@@ -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:
- Video overlay templates
- REST API endpoints and tokens
- REST API endpoints and tokens
- User authentication
- 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
......@@ -114,12 +140,29 @@ Threads communicate via Python Queues with structured messages:
### Web Dashboard API
#### Authentication
- `POST /auth/login` - User authentication
- `GET /api/tokens` - List JWT tokens
- `POST /api/tokens` - Create new token
- `DELETE /api/tokens/{id}` - Delete token
- `POST /auth/logout` - User logout
#### 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
- `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
......@@ -138,6 +181,47 @@ Threads communicate via Python Queues with structured messages:
- `CONFIG_UPDATE` - Configuration 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
Copyright (c) 2025 MBetter Project. All rights reserved.
......
......@@ -28,7 +28,7 @@ BUILD_CONFIG = {
# Platform-specific configurations
PLATFORM_CONFIG = {
'Windows': {
'executable_name': f"{BUILD_CONFIG['app_name']}.exe",
'executable_name': BUILD_CONFIG['app_name'] + '.exe',
'icon_ext': '.ico',
'additional_paths': [],
'exclude_modules': ['tkinter', 'unittest', 'doctest', 'pdb'],
......@@ -117,7 +117,7 @@ def collect_hidden_imports() -> List[str]:
return [
# PyQt5 modules
'PyQt5.QtCore',
'PyQt5.QtGui',
'PyQt5.QtGui',
'PyQt5.QtWidgets',
'PyQt5.QtMultimedia',
'PyQt5.QtMultimediaWidgets',
......@@ -137,6 +137,9 @@ def collect_hidden_imports() -> List[str]:
'requests',
'urllib3',
# Logging
'loguru',
# Other dependencies
'packaging',
'pkg_resources',
......@@ -283,16 +286,20 @@ def check_dependencies():
"""Check if all required dependencies are available"""
print("🔍 Checking dependencies...")
required_packages = ['PyInstaller', 'PyQt5']
# Map package names to their import names
required_packages = {
'PyInstaller': 'PyInstaller',
'PyQt5': 'PyQt5'
}
missing_packages = []
for package in required_packages:
for package_name, import_name in required_packages.items():
try:
__import__(package.lower().replace('-', '_'))
print(f" ✓ {package}")
__import__(import_name)
print(f" ✓ {package_name}")
except ImportError:
missing_packages.append(package)
print(f" ✗ {package}")
missing_packages.append(package_name)
print(f" ✗ {package_name}")
if missing_packages:
print(f"\n❌ Missing dependencies: {', '.join(missing_packages)}")
......
......@@ -21,11 +21,27 @@ from mbetterclient.config.settings import AppSettings
def setup_signal_handlers(app):
"""Setup signal handlers for graceful shutdown"""
# Use a mutable object to track shutdown state
shutdown_state = {'requested': False}
def signal_handler(signum, frame):
logging.info(f"Received signal {signum}, initiating shutdown...")
if app:
app.shutdown()
sys.exit(0)
if not shutdown_state['requested']:
logging.info("Received signal {}, initiating graceful shutdown...".format(signum))
shutdown_state['requested'] = True
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.SIGTERM, signal_handler)
......
......@@ -15,7 +15,7 @@ from urllib3.util.retry import Retry
from ..core.thread_manager import ThreadedComponent
from ..core.message_bus import MessageBus, Message, MessageType, MessageBuilder
from ..config.settings import APIClientConfig
from ..config.settings import ApiConfig
from ..config.manager import ConfigManager
from ..database.manager import DatabaseManager
......@@ -196,8 +196,8 @@ class SportsResponseHandler(ResponseHandler):
class APIClient(ThreadedComponent):
"""REST API Client component"""
def __init__(self, message_bus: MessageBus, db_manager: DatabaseManager,
config_manager: ConfigManager, settings: APIClientConfig):
def __init__(self, message_bus: MessageBus, db_manager: DatabaseManager,
config_manager: ConfigManager, settings: ApiConfig):
super().__init__("api_client", message_bus)
self.db_manager = db_manager
self.config_manager = config_manager
......@@ -248,8 +248,8 @@ class APIClient(ThreadedComponent):
def _setup_session(self):
"""Setup HTTP session with retry logic"""
retry_strategy = Retry(
total=self.settings.max_retries,
backoff_factor=self.settings.retry_backoff,
total=self.settings.retry_attempts,
backoff_factor=self.settings.retry_delay_seconds,
status_forcelist=[429, 500, 502, 503, 504],
)
......@@ -265,7 +265,7 @@ class APIClient(ThreadedComponent):
})
# Set timeout
self.session.timeout = self.settings.default_timeout
self.session.timeout = self.settings.timeout_seconds
def _load_endpoints(self):
"""Load API endpoints from configuration"""
......
......@@ -103,13 +103,22 @@ class MbetterClientApplication:
# Update settings from database
stored_settings = self.config_manager.get_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.web_host = self.settings.web_host
stored_settings.web_port = self.settings.web_port
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
# 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")
return True
......@@ -290,8 +299,9 @@ class MbetterClientApplication:
logger.info("MbetterClient application started successfully")
# Wait for shutdown
self.shutdown_event.wait()
# Wait for shutdown with a timeout to prevent indefinite hanging
while self.running and not self.shutdown_event.is_set():
self.shutdown_event.wait(timeout=1.0)
logger.info("Application shutdown initiated")
return self._cleanup()
......@@ -518,14 +528,16 @@ class MbetterClientApplication:
logger.info("Cleaning up application resources...")
try:
# Stop thread manager
# Stop thread manager with shorter timeout
if self.thread_manager:
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():
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
if self.message_bus:
......
This diff is collapsed.
......@@ -219,17 +219,92 @@ class Migration_004_AddUserPreferences(DatabaseMigration):
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
MIGRATIONS: List[DatabaseMigration] = [
Migration_001_InitialSchema(),
Migration_002_AddIndexes(),
Migration_003_AddTemplateVersioning(),
Migration_004_AddUserPreferences(),
Migration_005_CreateDefaultAdminUser(),
]
def get_applied_migrations(db_manager) -> List[str]:
"""Get list of applied migration versions"""
session = None
try:
session = db_manager.get_session()
......@@ -240,11 +315,13 @@ def get_applied_migrations(db_manager) -> List[str]:
logger.error(f"Failed to get applied migrations: {e}")
return []
finally:
session.close()
if session:
session.close()
def mark_migration_applied(db_manager, migration: DatabaseMigration) -> bool:
"""Mark migration as applied"""
session = None
try:
session = db_manager.get_session()
......@@ -267,14 +344,17 @@ def mark_migration_applied(db_manager, migration: DatabaseMigration) -> bool:
except Exception as e:
logger.error(f"Failed to mark migration as applied: {e}")
session.rollback()
if session:
session.rollback()
return False
finally:
session.close()
if session:
session.close()
def unmark_migration_applied(db_manager, version: str) -> bool:
"""Remove migration from applied list"""
session = None
try:
session = db_manager.get_session()
......@@ -290,10 +370,12 @@ def unmark_migration_applied(db_manager, version: str) -> bool:
except Exception as e:
logger.error(f"Failed to unmark migration: {e}")
session.rollback()
if session:
session.rollback()
return False
finally:
session.close()
if session:
session.close()
def run_migrations(db_manager) -> bool:
......
......@@ -10,7 +10,7 @@ from typing import Optional, Dict, Any
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QSlider, QFrame, QGraphicsView, QGraphicsScene,
QGraphicsVideoItem, QGraphicsProxyWidget
QGraphicsProxyWidget
)
from PyQt5.QtCore import (
Qt, QTimer, QThread, pyqtSignal, QUrl, QRect, QPropertyAnimation,
......@@ -32,32 +32,91 @@ from .templates import TemplateManager
logger = logging.getLogger(__name__)
class VideoWidget(QVideoWidget):
"""Custom video widget with overlay support"""
class OverlayWidget(QWidget):
"""Transparent overlay widget for rendering overlays on top of video"""
def __init__(self, parent=None):
super().__init__(parent)
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):
"""Set overlay engine for rendering"""
self.overlay_engine = overlay_engine
logger.info("Overlay engine set on overlay widget")
def paintEvent(self, event):
"""Custom paint event to render overlays"""
super().paintEvent(event)
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
# Render overlays if available
if self.overlay_engine:
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
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:
logger.error(f"Overlay rendering error: {e}")
finally:
painter.end()
import traceback
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):
......@@ -199,6 +258,8 @@ class PlayerWindow(QMainWindow):
self.video_widget.set_overlay_engine(self.overlay_engine)
layout.addWidget(self.video_widget, 1) # Stretch
logger.info(f"Composite video widget created with overlay engine: {self.overlay_engine is not None}")
# Controls
self.controls = PlayerControlsWidget()
self.controls.play_pause_clicked.connect(self.toggle_play_pause)
......@@ -219,7 +280,7 @@ class PlayerWindow(QMainWindow):
def setup_media_player(self):
"""Setup media player"""
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
self.media_player.stateChanged.connect(self.on_state_changed)
......@@ -282,8 +343,9 @@ class PlayerWindow(QMainWindow):
if self.overlay_engine:
self.overlay_engine.update_playback_position(position, duration)
# Trigger overlay repaint
self.video_widget.update()
# Trigger overlay repaint on the overlay widget
if hasattr(self.video_widget, 'overlay_widget'):
self.video_widget.overlay_widget.update()
def on_duration_changed(self, duration):
"""Handle duration changes"""
......@@ -381,6 +443,9 @@ class QtVideoPlayer(ThreadedComponent):
# Create player window
self.window = PlayerWindow(self.settings, self.overlay_engine)
# Load default template
self._load_default_template()
# Subscribe to messages
self.message_bus.subscribe(self.name, MessageType.VIDEO_PLAY, self._handle_video_play)
self.message_bus.subscribe(self.name, MessageType.VIDEO_PAUSE, self._handle_video_pause)
......@@ -396,6 +461,56 @@ class QtVideoPlayer(ThreadedComponent):
logger.error(f"QtVideoPlayer initialization failed: {e}")
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):
"""Main run loop"""
try:
......@@ -425,6 +540,14 @@ class QtVideoPlayer(ThreadedComponent):
if (self.window and self.window.media_player.state() == QMediaPlayer.PlayingState):
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
self.heartbeat()
......
......@@ -7,7 +7,6 @@ import logging
import logging.handlers
from pathlib import Path
from typing import Optional
from loguru import logger as loguru_logger
def setup_logging(level: int = logging.INFO, log_file: Optional[str] = None,
......@@ -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:
"""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
loguru_logger.remove()
......
......@@ -3,7 +3,7 @@ Web dashboard for MbetterClient with authentication and configuration
"""
from .app import WebDashboard
from .auth import AuthManager, jwt_required
from .auth import AuthManager
from .api import DashboardAPI
__all__ = [
......
......@@ -288,12 +288,12 @@ class DashboardAPI:
users = self.db_manager.get_all_users()
user_list = [
{
"id": user.id,
"username": user.username,
"email": user.email,
"is_admin": user.is_admin,
"created_at": user.created_at.isoformat(),
"last_login": user.last_login.isoformat() if user.last_login else None
"id": user["id"],
"username": user["username"],
"email": user["email"],
"is_admin": user["is_admin"],
"created_at": user["created_at"].isoformat() if user["created_at"] else None,
"last_login": user["last_login"].isoformat() if user["last_login"] else None
}
for user in users
]
......@@ -321,10 +321,10 @@ class DashboardAPI:
return {
"success": True,
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"is_admin": user.is_admin
"id": user["id"],
"username": user["username"],
"email": user["email"],
"is_admin": user["is_admin"]
}
}
else:
......@@ -349,7 +349,7 @@ class DashboardAPI:
logger.error(f"User deletion error: {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"""
try:
from .auth import AuthManager
......@@ -359,11 +359,19 @@ class DashboardAPI:
return {"error": "Auth manager not available"}
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:
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,
expires_hours: int = 8760) -> Dict[str, Any]:
......@@ -378,14 +386,14 @@ class DashboardAPI:
result = auth_manager.create_api_token(user_id, token_name, expires_hours)
if result:
token, token_record = result
token, token_data = result
return {
"success": True,
"token": token,
"token_info": {
"id": token_record.id,
"name": token_record.token_name,
"expires_at": token_record.expires_at.isoformat()
"id": token_data['id'],
"name": token_data['name'],
"expires_at": token_data['expires_at'].isoformat() if isinstance(token_data['expires_at'], datetime) else token_data['expires_at']
}
}
else:
......@@ -457,4 +465,101 @@ class DashboardAPI:
except Exception as e:
logger.error(f"Test message error: {e}")
return {"error": str(e)}
\ No newline at end of file
return {"error": str(e)}
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):
# User loader for Flask-Login
@login_manager.user_loader
def load_user(user_id):
return self.db_manager.get_user_by_id(int(user_id))
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_manager.expired_token_loader
......
......@@ -13,7 +13,7 @@ from flask_jwt_extended import create_access_token, decode_token
import jwt
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__)
......@@ -26,13 +26,26 @@ class AuthenticatedUser(UserMixin):
self.username = username
self.email = email
self.is_admin = is_admin
self.is_authenticated = True
self.is_active = True
self.is_anonymous = False
# Don't set Flask-Login properties - they are handled by UserMixin
def get_id(self):
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]:
return {
'id': self.id,
......@@ -98,8 +111,8 @@ class AuthManager:
logger.error(f"Authentication error: {e}")
return None
def create_user(self, username: str, email: str, password: str,
is_admin: bool = False) -> Optional[User]:
def create_user(self, username: str, email: str, password: str,
is_admin: bool = False) -> Optional[Dict[str, Any]]:
"""Create new user"""
try:
# Check if user already exists
......@@ -123,9 +136,9 @@ class AuthManager:
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}")
return saved_user
return saved_user_data
except Exception as e:
logger.error(f"User creation error: {e}")
......@@ -178,7 +191,7 @@ class AuthManager:
# Store token in database
api_token = APIToken(
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),
expires_at=datetime.utcnow() + timedelta(hours=expires_hours),
created_at=datetime.utcnow()
......@@ -229,27 +242,30 @@ class AuthManager:
return None
def revoke_jwt_token(self, token: str) -> bool:
"""Revoke JWT token"""
"""Delete JWT token"""
try:
token_hash = self._hash_token(token)
api_token = self.db_manager.get_api_token_by_hash(token_hash)
if api_token:
api_token.revoked = True
api_token.updated_at = datetime.utcnow()
self.db_manager.save_api_token(api_token)
# Delete the token completely from database
success = self.db_manager.delete_api_token(api_token.id)
logger.info(f"JWT token revoked: {api_token.token_name}")
return True
if success:
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
except Exception as e:
logger.error(f"JWT token revocation error: {e}")
logger.error(f"JWT token deletion error: {e}")
return False
def create_api_token(self, user_id: int, token_name: str,
expires_hours: int = 8760) -> Optional[Tuple[str, APIToken]]: # 1 year default
def create_api_token(self, user_id: int, token_name: str,
expires_hours: int = 8760) -> Optional[Tuple[str, Dict[str, Any]]]: # 1 year default
"""Create long-lived API token"""
try:
user = self.db_manager.get_user_by_id(user_id)
......@@ -259,20 +275,25 @@ class AuthManager:
# Generate secure token
token = secrets.token_urlsafe(32)
token_hash = self._hash_token(token)
expires_at = datetime.utcnow() + timedelta(hours=expires_hours)
# Create API token record
api_token = APIToken(
user_id=user.id,
token_name=token_name,
name=token_name,
token_hash=token_hash,
expires_at=datetime.utcnow() + timedelta(hours=expires_hours),
expires_at=expires_at,
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}")
return token, saved_token
if token_data:
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:
logger.error(f"API token creation error: {e}")
......@@ -307,7 +328,7 @@ class AuthManager:
'user_id': user.id,
'username': user.username,
'is_admin': user.is_admin,
'token_name': api_token.token_name,
'token_name': api_token.name,
'token_id': api_token.id
}
......@@ -322,11 +343,11 @@ class AuthManager:
return [
{
'id': token.id,
'name': token.token_name,
'name': token.name,
'created_at': token.created_at.isoformat(),
'expires_at': token.expires_at.isoformat(),
'last_used': token.last_used.isoformat() if token.last_used else None,
'revoked': token.revoked
'last_used': token.last_used_at.isoformat() if token.last_used_at else None,
'revoked': not token.is_active
}
for token in tokens
]
......@@ -335,22 +356,25 @@ class AuthManager:
return []
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:
api_token = self.db_manager.get_api_token_by_id(token_id)
if api_token and api_token.user_id == user_id:
api_token.revoked = True
api_token.updated_at = datetime.utcnow()
self.db_manager.save_api_token(api_token)
# Delete the token completely from database
success = self.db_manager.delete_api_token(token_id)
logger.info(f"API token revoked: {api_token.token_name}")
return True
if success:
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
except Exception as e:
logger.error(f"API token revocation error: {e}")
logger.error(f"API token deletion error: {e}")
return False
def cleanup_expired_tokens(self):
......
......@@ -47,8 +47,8 @@ def index():
@main_bp.route('/video')
@login_required
def video_control():
@login_required
def video_control_page():
"""Video control page"""
try:
return render_template('dashboard/video.html',
......@@ -182,6 +182,20 @@ def login():
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')
......@@ -276,7 +290,9 @@ def video_control():
if not action:
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)
except Exception as e:
......@@ -329,6 +345,32 @@ def get_configuration():
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.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
......@@ -499,6 +541,48 @@ def send_test_message():
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_bp.route('/token', methods=['POST'])
def create_auth_token():
......
......@@ -5,16 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ page_title | default("Dashboard") }} - {{ app_name }}{% endblock %}</title>
<!-- Offline-first CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap.min.css') }}">
<!-- CSS from CDN -->
<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/fontawesome.min.css') }}">
<!-- CDN fallbacks -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
onerror="this.remove()">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
onerror="this.remove()">
{% block head %}{% endblock %}
</head>
......@@ -39,19 +33,25 @@
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.video_control' %}active{% endif %}"
href="{{ url_for('main.video_control') }}">
<a class="nav-link {% if request.endpoint == 'main.video_control_page' %}active{% endif %}"
href="{{ url_for('main.video_control_page') }}">
<i class="fas fa-video me-1"></i>Video Control
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.templates' %}active{% endif %}"
<a class="nav-link {% if request.endpoint == 'main.templates' %}active{% endif %}"
href="{{ url_for('main.templates') }}">
<i class="fas fa-layer-group me-1"></i>Templates
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.api_tokens' %}active{% endif %}"
<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') }}">
<i class="fas fa-key me-1"></i>API Tokens
</a>
......@@ -144,14 +144,10 @@
</div>
{% endif %}
<!-- Offline-first JavaScript -->
<script src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script>
<!-- JavaScript from CDN and local -->
<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>
<!-- CDN fallbacks -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"
onerror="this.remove()"></script>
{% if current_user.is_authenticated %}
<script id="dashboard-config" type="application/json">
{
......
{% 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
This diff is collapsed.
{% 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 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
PyQt5==5.15.10
Flask==3.0.3
Flask-Login==0.6.3
Flask-WTF==1.2.1
Flask-JWT-Extended==4.6.0
SQLAlchemy==2.0.25
requests==2.31.0
Flask>=2.3.0
Flask-Login>=0.6.0
Flask-WTF>=1.1.0
Flask-JWT-Extended>=4.4.0
requests>=2.28.0
# GUI - PyQt5
PyQt5>=5.15.0
# Database
sqlite3
SQLAlchemy>=2.0.0
# GUI and multimedia
opencv-python==4.9.0.80
Pillow==10.2.0
# Security and authentication
cryptography>=3.4.8
bcrypt>=4.0.0
# Web interface
Werkzeug==3.0.1
Jinja2==3.1.3
WTForms==3.1.1
MarkupSafe==2.1.4
itsdangerous==2.1.2
# Web framework utilities
Werkzeug>=2.3.0
Jinja2>=3.1.0
WTForms>=3.0.0
# Security and authentication
cryptography==42.0.4
PyJWT==2.8.0
bcrypt==4.1.2
# Networking and API
urllib3==2.2.1
certifi==2024.2.2
charset-normalizer==3.3.2
idna==3.6
# Utilities
python-dateutil==2.8.2
six==1.16.0
click==8.1.7
colorlog==6.8.2
# Development and building
PyInstaller==6.3.0
setuptools==69.1.0
wheel==0.42.0
# Cross-platform support
psutil==5.9.8
platformdirs==4.2.0
# Configuration management
python-dotenv==1.0.1
configparser==6.0.0
# Configuration and environment
python-dotenv>=0.19.0
# Logging
loguru==0.7.2
# Utilities and system
psutil>=5.8.0
click>=8.0.0
# Video and image processing
opencv-python>=4.5.0
Pillow>=9.0.0
# Testing (optional)
pytest==8.0.0
pytest-qt==4.3.1
# Logging
loguru>=0.7.0
# Video processing
ffmpeg-python==0.2.0
\ No newline at end of file
# Building and packaging
PyInstaller>=5.0.0
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment