From 0745a4eb753bdead5c1fb1c1dc785cdc91a63b25 Mon Sep 17 00:00:00 2001 From: Nate Anderson Date: Wed, 18 Jun 2025 20:59:24 -0600 Subject: [PATCH] It works baby, seems like timing could be improved or something, but it freakin works --- .gitignore | 2 + README.md | 122 ++- ROADMAP.md | 395 +++++++ bin/mumbullet.dart | 267 ++++- build.yaml | 15 + config.json | 21 + dart_test.yaml | 20 + dev-compose.yml | 39 + docker-compose.yml | 30 + flake.nix | 3 + lib/mumbullet.dart | 19 +- lib/src/audio/converter.dart | 114 +++ lib/src/audio/downloader.dart | 311 ++++++ lib/src/audio/opus_encoder.dart | 130 +++ lib/src/command/command_handler.dart | 271 +++++ lib/src/command/command_parser.dart | 185 ++++ lib/src/config/config.dart | 119 +++ lib/src/config/config.g.dart | 68 ++ lib/src/dashboard/api.dart | 210 ++++ lib/src/dashboard/auth.dart | 167 +++ lib/src/dashboard/server.dart | 962 ++++++++++++++++++ lib/src/logging/logger.dart | 113 ++ lib/src/mumble/audio_streamer.dart | 364 +++++++ lib/src/mumble/connection.dart | 382 +++++++ lib/src/mumble/message_handler.dart | 157 +++ lib/src/mumble/models.dart | 41 + lib/src/queue/music_queue.dart | 246 +++++ lib/src/storage/database.dart | 430 ++++++++ lib/src/storage/permission_manager.dart | 130 +++ pubspec.lock | 254 ++++- pubspec.yaml | 19 +- scripts/dev-server.sh | 113 ++ scripts/start_dev_environment.sh | 19 + test/audio/downloader_simple_test.dart | 210 ++++ test/audio/downloader_test.dart | 342 +++++++ test/command_handler_test.dart | 52 + test/config_test.dart | 74 ++ test/fixtures/mumble-server.conf | 71 ++ test/fixtures/murmur.ini | 69 ++ test/fixtures/test_config.json | 21 + test/fixtures/test_configs/admin_config.json | 21 + test/fixtures/test_configs/user_config.json | 21 + test/integration/README.md | 103 ++ test/integration/command_downloader_test.dart | 320 ++++++ test/integration/connection_test.dart | 272 +++++ test/integration/dart_test.yaml | 7 + test/integration/docker-compose.test.yml | 20 + .../downloader_integration_test.dart | 256 +++++ test/integration_test.dart | 69 ++ test/mumbullet_test.dart | 33 +- 50 files changed, 7679 insertions(+), 20 deletions(-) create mode 100644 ROADMAP.md create mode 100644 build.yaml create mode 100644 config.json create mode 100644 dart_test.yaml create mode 100644 dev-compose.yml create mode 100644 docker-compose.yml create mode 100644 lib/src/audio/converter.dart create mode 100644 lib/src/audio/downloader.dart create mode 100644 lib/src/audio/opus_encoder.dart create mode 100644 lib/src/command/command_handler.dart create mode 100644 lib/src/command/command_parser.dart create mode 100644 lib/src/config/config.dart create mode 100644 lib/src/config/config.g.dart create mode 100644 lib/src/dashboard/api.dart create mode 100644 lib/src/dashboard/auth.dart create mode 100644 lib/src/dashboard/server.dart create mode 100644 lib/src/logging/logger.dart create mode 100644 lib/src/mumble/audio_streamer.dart create mode 100644 lib/src/mumble/connection.dart create mode 100644 lib/src/mumble/message_handler.dart create mode 100644 lib/src/mumble/models.dart create mode 100644 lib/src/queue/music_queue.dart create mode 100644 lib/src/storage/database.dart create mode 100644 lib/src/storage/permission_manager.dart create mode 100644 scripts/dev-server.sh create mode 100755 scripts/start_dev_environment.sh create mode 100644 test/audio/downloader_simple_test.dart create mode 100644 test/audio/downloader_test.dart create mode 100644 test/command_handler_test.dart create mode 100644 test/config_test.dart create mode 100644 test/fixtures/mumble-server.conf create mode 100644 test/fixtures/murmur.ini create mode 100644 test/fixtures/test_config.json create mode 100644 test/fixtures/test_configs/admin_config.json create mode 100644 test/fixtures/test_configs/user_config.json create mode 100644 test/integration/README.md create mode 100644 test/integration/command_downloader_test.dart create mode 100644 test/integration/connection_test.dart create mode 100644 test/integration/dart_test.yaml create mode 100644 test/integration/docker-compose.test.yml create mode 100644 test/integration/downloader_integration_test.dart create mode 100644 test/integration_test.dart diff --git a/.gitignore b/.gitignore index af60d18..74873bd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ # Created by `dart pub` .dart_tool/ .direnv/ +cache/ +**/.claude/settings.local.json diff --git a/README.md b/README.md index 3816eca..10d5d46 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,120 @@ -A sample command-line application with an entrypoint in `bin/`, library code -in `lib/`, and example unit test in `test/`. +# MumBullet - Mumble Music Bot + +A Dart-based framework for building a music bot that connects to Mumble servers, processes chat commands, downloads audio from URLs, and streams audio back to the server with queue management and admin dashboard functionality. + +## Current Status + +This project is currently in development. The framework and architecture are set up, but the Mumble connectivity is not yet implemented due to limitations in the dumble library documentation. + +### What Works +- Configuration management +- Logging system +- Command line interface + +### What's Planned +- Mumble server connection +- Command processing with permissions +- YouTube audio downloading and streaming +- Queue management +- Admin dashboard + +## Setup and Installation + +### Prerequisites + +- Dart SDK (version 3.7.3 or later) +- FFmpeg (for audio processing) +- yt-dlp (for YouTube downloads) +- Docker and Docker Compose (for local testing) + +### Installation + +1. Clone the repository +2. Install the dependencies: + ```bash + dart pub get + ``` +3. Generate the JSON serialization code: + ```bash + dart run build_runner build + ``` +4. Configure the bot by editing `config.json` +5. Run the bot: + ```bash + dart bin/mumbullet.dart + ``` + +### Docker Test Environment + +The project includes a Docker Compose configuration for local testing with a Mumble server: + +```bash +docker-compose up +``` + +This will start a Mumble server on port 64738 with the password "testpass". + +## Configuration + +The bot is configured using a JSON file (`config.json` by default). You can specify a different configuration file using the `-c` command line option. + +Example configuration: + +```json +{ + "mumble": { + "server": "localhost", + "port": 64738, + "username": "MusicBot", + "password": "testpass", + "channel": "Music" + }, + "bot": { + "command_prefix": "!", + "default_permission_level": 0, + "max_queue_size": 50, + "cache_directory": "./cache", + "max_cache_size_gb": 5 + }, + "dashboard": { + "port": 8080, + "admin_username": "admin", + "admin_password": "changeme" + } +} +``` + +## Project Structure + +The project is organized into the following directories: + +- `bin/` - Main executable +- `lib/` - Library code + - `src/` - Source code + - `config/` - Configuration management (implemented) + - `logging/` - Logging system (implemented) + - `audio/` - Audio downloading and conversion (planned) + - `command/` - Command parsing and handling (planned) + - `dashboard/` - Web dashboard (planned) + - `mumble/` - Mumble connection and audio streaming (planned) + - `queue/` - Music queue management (planned) + - `storage/` - Database and cache management (planned) +- `test/` - Test files +- `web/` - Web dashboard static files (planned) + +## Contributing + +Contributions to implement the planned features are welcome! The project needs help with: + +1. Understanding and implementing the dumble library for Mumble connectivity +2. Implementing audio streaming using FFmpeg and yt-dlp +3. Building the web dashboard interface +4. Creating a robust queue management system + +## Roadmap + +See the ROADMAP.md file for a detailed development plan. + +## License + +This project is open source and available for collaborative development. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..b0d4170 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,395 @@ + +# Mumble Music Bot Design Document + +## Project Overview +A Dart-based music bot that connects to Mumble servers, processes chat commands, downloads audio from URLs (primarily YouTube), and streams audio back to the server with queue management and admin dashboard functionality. + +## Technology Stack +- **Language**: Dart +- **Mumble Protocol**: dumble (Dart Mumble library) +- **Audio Processing**: FFmpeg via process execution +- **YouTube Download**: yt-dlp via process execution +- **Web Framework**: shelf for admin dashboard +- **Storage**: SQLite for metadata, filesystem for audio cache +- **Authentication**: Simple username/password with session tokens + +## Development Steps + +### Phase 1: Core Infrastructure and Testing Setup + +#### Step 1: Project Setup and Dependencies +**Scope**: Initialize Dart project with required dependencies +**Deliverable**: Working Dart project with all dependencies configured +**Test Criteria**: +- `dart pub get` runs without errors +- All imports resolve correctly +- Basic main.dart can be executed + +**Dependencies to add**: +```yaml +dependencies: + dumble: ^latest_version + shelf: ^1.4.0 + shelf_router: ^1.1.0 + shelf_static: ^1.1.0 + sqlite3: ^2.1.0 + crypto: ^3.0.3 + path: ^1.8.3 + logging: ^1.2.0 + args: ^2.4.0 + json_annotation: ^4.8.0 + +dev_dependencies: + test: ^1.24.0 + json_serializable: ^6.6.0 + build_runner: ^2.4.0 +``` + +#### Step 2: Docker Test Environment +**Scope**: Create Docker Compose setup for local Mumble server testing +**Deliverable**: Docker Compose file and configuration for local testing +**Test Criteria**: +- `docker-compose up` starts local Mumble server +- Server is accessible on localhost with known credentials +- Configuration allows multiple bot connections for testing + +**Docker Compose Structure**: +```yaml +version: '3.8' +services: + mumble-server: + image: coppit/mumble-server + ports: + - "64738:64738" + - "64738:64738/udp" + environment: + - MUMBLE_SERVERPASSWORD=testpass + volumes: + - ./test/mumble-config:/opt/mumble/config + - ./test/mumble-data:/opt/mumble/data +``` + +#### Step 3: Configuration Management +**Scope**: Create configuration system for bot settings +**Deliverable**: Configuration class that loads from JSON file +**Test Criteria**: +- Can load configuration from `config.json` +- Validates required fields (server, username, password, etc.) +- Provides sensible defaults for optional fields + +**Configuration Structure**: +```json +{ + "mumble": { + "server": "localhost", + "port": 64738, + "username": "MusicBot", + "password": "optional_password", + "channel": "Music" + }, + "bot": { + "command_prefix": "!", + "default_permission_level": 0, + "max_queue_size": 50, + "cache_directory": "./cache", + "max_cache_size_gb": 5 + }, + "dashboard": { + "port": 8080, + "admin_username": "admin", + "admin_password": "changeme" + } +} +``` + +#### Step 4: Logging System +**Scope**: Implement structured logging throughout the application +**Deliverable**: Centralized logging configuration with different log levels +**Test Criteria**: +- Logs can be written to console and file +- Different log levels work correctly (debug, info, warn, error) +- Log rotation works for file output + +### Phase 2: Mumble Protocol Implementation + +#### Step 5: Basic Mumble Connection with dumble +**Scope**: Establish connection to Mumble server using dumble library +**Deliverable**: Class that can connect and authenticate to Mumble server +**Test Criteria**: +- Successfully connects to test Mumble server using dumble +- Authenticates with username/password +- Handles connection failures gracefully +- Can disconnect cleanly + +#### Step 6: Mumble Message Handling and Sending +**Scope**: Listen for chat messages and send responses using dumble +**Deliverable**: Message listener and sender that can receive and send chat messages +**Test Criteria**: +- Receives all chat messages from server +- Can send chat messages back to channel/users +- Correctly parses message content and sender +- Filters messages by configurable command prefix +- Handles different message types appropriately + +#### Step 7: Mumble Audio Streaming +**Scope**: Stream audio data to Mumble server using dumble +**Deliverable**: Audio streaming functionality +**Test Criteria**: +- Can send audio packets to Mumble server via dumble +- Audio plays correctly in the channel +- Handles audio format conversion (PCM) +- Manages audio timing and buffering + +### Phase 3: Command Processing + +#### Step 8: Command Parser with Configurable Prefix +**Scope**: Parse and validate bot commands from chat messages with configurable prefix +**Deliverable**: Command parsing system with validation +**Test Criteria**: +- Uses command prefix from configuration +- Correctly identifies commands with prefix +- Parses command arguments +- Validates command syntax +- Returns appropriate error messages for invalid commands + +**Commands to support**: +- `play ` - Clear queue and immediately play song +- `queue ` - Add song to queue +- `skip` - Skip current song +- `list` - Show current queue +- `clear` - Clear queue (requires permissions) +- `shuffle` - Shuffle downloaded songs and start playing +- `help` - Show available commands (sent as chat response) + +#### Step 9: User Permission System +**Scope**: Implement dynamic user permission system +**Deliverable**: Permission system that auto-creates users and manages access levels +**Test Criteria**: +- Auto-creates users when they first send commands +- Assigns default permission level from config +- Validates user permissions for protected commands +- Provides clear error messages for insufficient permissions +- Supports permission levels: 0=no access, 1=view only, 2=read/write, 3=admin + +### Phase 4: Audio Processing + +#### Step 10: YouTube Audio Downloader +**Scope**: Download audio from YouTube URLs using yt-dlp +**Deliverable**: Service that downloads audio files from URLs +**Test Criteria**: +- Successfully downloads audio from YouTube URLs +- Extracts audio in appropriate format (WAV/MP3) +- Handles invalid URLs gracefully +- Provides download progress feedback +- Supports other platforms (SoundCloud, direct MP3 links) + +#### Step 11: Audio Format Conversion +**Scope**: Convert downloaded audio to Mumble-compatible format +**Deliverable**: Audio converter using FFmpeg +**Test Criteria**: +- Converts various audio formats to PCM +- Maintains acceptable audio quality +- Handles conversion errors gracefully +- Supports different sample rates and bit depths + +#### Step 12: Audio Cache Management +**Scope**: Cache downloaded audio files with size limits +**Deliverable**: Caching system for audio files +**Test Criteria**: +- Stores downloaded files in cache directory +- Implements LRU eviction when cache size limit reached +- Prevents duplicate downloads of same URL +- Provides cache statistics (size, file count) +- Can manually purge cache + +### Phase 5: Queue Management + +#### Step 13: Music Queue Implementation +**Scope**: Implement thread-safe music queue with play vs queue distinction +**Deliverable**: Queue system for managing song playback order +**Test Criteria**: +- Supports adding songs to queue (`queue` command) +- Supports clearing queue and immediate play (`play` command) +- Maintains FIFO ordering for queued songs +- Thread-safe for concurrent access +- Supports queue manipulation (skip, clear, shuffle) +- Enforces maximum queue size limit + +#### Step 14: Playback Controller with Shuffle +**Scope**: Control music playback, queue progression, and shuffle functionality +**Deliverable**: Playback manager that handles audio streaming and shuffle +**Test Criteria**: +- Automatically plays next song when current finishes +- Handles pause/resume functionality +- Manages audio streaming to Mumble via dumble +- Provides playback status information +- Handles empty queue gracefully +- Implements shuffle from cached songs + +### Phase 6: Data Persistence + +#### Step 15: Database Schema +**Scope**: Design and implement SQLite database schema with permissions tables +**Deliverable**: Database schema for storing bot data +**Test Criteria**: +- Creates all required tables successfully +- Provides data access layer +- Handles database connection errors + +**Tables**: +```sql +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE permissions ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + description TEXT +); +-- Insert default permissions: (0, 'none'), (1, 'view'), (2, 'read_write'), (3, 'admin') + +CREATE TABLE user_permissions ( + user_id INTEGER, + permission_level INTEGER DEFAULT 0, + granted_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id), + FOREIGN KEY (permission_level) REFERENCES permissions (id) +); + +CREATE TABLE cache_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT UNIQUE NOT NULL, + file_path TEXT NOT NULL, + title TEXT, + duration INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_accessed DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +``` + +#### Step 16: Data Access Layer +**Scope**: Implement repository pattern for data access +**Deliverable**: Repository classes for database operations +**Test Criteria**: +- Provides CRUD operations for all entities +- Uses prepared statements for security +- Handles database exceptions gracefully +- Auto-creates users with default permissions + +### Phase 7: Web Dashboard + +#### Step 17: Dashboard Authentication +**Scope**: Implement simple authentication for admin dashboard +**Deliverable**: Login system with session management +**Test Criteria**: +- Login form accepts username/password +- Creates secure session tokens +- Validates sessions on protected routes +- Provides logout functionality +- Sessions expire after inactivity + +#### Step 18: Dashboard API Endpoints +**Scope**: Create REST API for dashboard functionality including user permission management +**Deliverable**: API endpoints for dashboard operations +**Test Criteria**: +- GET /api/queue - Returns current queue +- DELETE /api/queue - Clears queue +- GET /api/cache - Returns cache statistics +- DELETE /api/cache - Purges cache +- GET /api/users - Returns user list with permission levels +- PUT /api/users/:id/permissions - Update user permission level +- POST /api/users - Manually create user (admin only) + +#### Step 19: Dashboard Frontend +**Scope**: Create simple HTML/CSS/JS frontend for dashboard with user management +**Deliverable**: Web interface for bot administration +**Test Criteria**: +- Displays current queue with song information +- Shows cache usage statistics +- Provides buttons for queue/cache management +- User management interface for permission levels +- Responsive design for mobile devices +- Permission level selector for each user + +### Phase 8: Integration Testing + +#### Step 20: Integration Test Bot +**Scope**: Create automated test bot that simulates user interactions +**Deliverable**: Test bot that can join Mumble and send commands +**Test Criteria**: +- Connects to test Mumble server +- Sends various commands to music bot +- Verifies expected responses in chat +- Confirms audio playback occurs +- Tests permission levels by simulating different users +- Validates queue operations work correctly + +#### Step 21: End-to-End Integration Testing +**Scope**: Test complete bot functionality using test bot +**Deliverable**: Comprehensive integration tests +**Test Criteria**: +- Bot connects to Mumble and processes commands correctly +- Audio download and playback works with test bot verification +- Queue management functions properly (play vs queue distinction) +- Permission system works (test bot tries restricted commands) +- Dashboard controls affect bot behavior as expected +- Cache management operates within limits +- Shuffle functionality works with cached songs + +#### Step 22: Error Handling and Documentation +**Scope**: Implement comprehensive error handling and create documentation +**Deliverable**: Robust error handling and complete documentation +**Test Criteria**: +- Graceful handling of network failures +- Recovery from Mumble disconnections +- Proper error messages to users via chat +- Logging of all errors for debugging +- README with setup instructions including Docker usage +- API documentation for dashboard endpoints +- Configuration file documentation + +## Testing Strategy + +Each step should include: +1. **Unit Tests**: Test individual components in isolation +2. **Integration Tests**: Test component interactions +3. **Manual Testing**: Verify functionality works as expected using Docker environment +4. **Automated Integration Tests**: Use test bot to verify end-to-end functionality + +## Success Criteria + +- Bot successfully connects to Mumble server via dumble library +- Commands are processed correctly with configurable prefix and proper authorization +- Audio downloads and plays without significant delay +- Queue vs play distinction works correctly +- Permission system auto-creates users and manages access levels properly +- Dashboard provides useful administrative control over user permissions +- System handles errors gracefully and provides chat feedback +- Cache management prevents unlimited storage growth +- Docker environment enables easy local testing +- Integration test bot can verify functionality automatically + +## Development Workflow + +1. Start each development session with `docker-compose up` to run local Mumble server +2. Develop and test individual components using unit tests +3. Test integration using manual connections to Docker Mumble server +4. Use integration test bot to verify end-to-end functionality +5. Validate dashboard functionality affects bot behavior correctly + +## Risk Mitigation + +- **External Dependencies**: yt-dlp and FFmpeg failures should be handled gracefully with chat notifications +- **Network Issues**: Implement retry logic and connection recovery with dumble +- **Resource Limits**: Monitor memory and disk usage to prevent system overload +- **Security**: Validate all user inputs and sanitize file paths +- **Permission Management**: Default to restrictive permissions and require explicit grants diff --git a/bin/mumbullet.dart b/bin/mumbullet.dart index 4d07d00..b8ca19c 100644 --- a/bin/mumbullet.dart +++ b/bin/mumbullet.dart @@ -1,5 +1,266 @@ -import 'package:mumbullet/mumbullet.dart' as mumbullet; +import 'dart:io'; +import 'dart:async'; +import 'package:args/args.dart'; +import 'package:logging/logging.dart'; +import 'package:mumbullet/mumbullet.dart'; +import 'package:path/path.dart' as path; -void main(List arguments) { - print('Hello world: ${mumbullet.calculate()}!'); +Future main(List arguments) async { + // Parse command line arguments + final parser = ArgParser() + ..addOption('config', + abbr: 'c', + defaultsTo: 'config.json', + help: 'Path to configuration file') + ..addOption('log-level', + abbr: 'l', + defaultsTo: 'info', + allowed: ['debug', 'info', 'warning', 'error'], + help: 'Log level (debug, info, warning, error)') + ..addFlag('console-log', + abbr: 'o', + defaultsTo: true, + help: 'Log to console') + ..addOption('log-file', + abbr: 'f', + help: 'Path to log file') + ..addFlag('help', + abbr: 'h', + negatable: false, + help: 'Display this help message'); + + try { + final results = parser.parse(arguments); + + if (results['help'] == true) { + print('Mumble Music Bot - A Dart-based music bot for Mumble servers'); + print(''); + print('Usage:'); + print('dart bin/mumbullet.dart [options]'); + print(''); + print('Options:'); + print(parser.usage); + exit(0); + } + + // Set up logging + final logLevelString = results['log-level'] as String; + final Level logLevel = _parseLogLevel(logLevelString); + final logManager = LogManager.getInstance(); + + logManager.initialize( + level: logLevel, + logToConsole: results['console-log'] as bool, + logFilePath: results['log-file'] as String?, + ); + + // Load configuration + final configPath = results['config'] as String; + final logger = logManager.logger; + + logger.info('Starting Mumble Music Bot'); + logger.info('Loading configuration from $configPath'); + + final config = AppConfig.fromFile(configPath); + try { + config.validate(); + logger.info('Configuration loaded successfully'); + } catch (e) { + logger.severe('Configuration validation error: $e'); + exit(1); + } + + // Initialize database + final dbPath = path.join(config.bot.cacheDirectory, 'mumbullet.db'); + logger.info('Initializing database: $dbPath'); + final database = DatabaseManager(dbPath); + + // Initialize permission manager + final permissionManager = PermissionManager(database, config.bot); + + // Create Mumble connection + logger.info('Connecting to Mumble server ${config.mumble.server}:${config.mumble.port}'); + final mumbleConnection = MumbleConnection(config.mumble); + + // Create message handler + final messageHandler = MumbleMessageHandler(mumbleConnection, config.bot); + + // Initialize audio services + logger.info('Initializing audio services'); + final audioConverter = AudioConverter(); + final youtubeDownloader = YoutubeDownloader(config.bot, database); + final audioStreamer = MumbleAudioStreamer(mumbleConnection); + + // Create music queue + final musicQueue = MusicQueue(config.bot, audioStreamer, audioConverter); + + // Create command parser with permission callback + final commandParser = CommandParser( + messageHandler, + config.bot, + (user) => permissionManager.getPermissionLevel(user), + ); + + // Create command handler + final commandHandler = CommandHandler( + commandParser, + musicQueue, + youtubeDownloader, + config.bot, + ); + + // Set up event listeners + mumbleConnection.connectionState.listen((isConnected) { + if (isConnected) { + logger.info('Connected to Mumble server'); + } else { + logger.info('Disconnected from Mumble server'); + } + }); + + mumbleConnection.userJoined.listen((user) { + logger.info('User joined: ${user.name}'); + }); + + mumbleConnection.userLeft.listen((user) { + logger.info('User left: ${user.name}'); + }); + + // Listen to music queue events + musicQueue.events.listen((event) { + final eventType = event['event'] as String; + final data = event['data'] as Map; + + logger.info('Music queue event: $eventType'); + + // Log important events + switch (eventType) { + case 'songStarted': + final song = data['song'] as Song; + logger.info('Now playing: ${song.title}'); + break; + case 'songFinished': + final song = data['song'] as Song; + logger.info('Finished playing: ${song.title}'); + break; + case 'queueEmpty': + logger.info('Music queue is now empty'); + break; + } + }); + + // Connect to Mumble server + try { + await mumbleConnection.connect(); + + // Send a welcome message to the channel + if (mumbleConnection.isConnected) { + await mumbleConnection.sendChannelMessage('MumBullet Music Bot is online! Type ${config.bot.commandPrefix}help for a list of commands.'); + logger.info('Sent welcome message to channel'); + } + } catch (e) { + logger.warning('Failed to connect to Mumble server: $e'); + logger.info('The bot will automatically attempt to reconnect...'); + } + + logger.info('MumBullet is now running with the following features:'); + logger.info('- Mumble server connection'); + logger.info('- Command processing with permissions'); + logger.info('- YouTube audio downloading and streaming'); + logger.info('- Queue management'); + logger.info('- Admin dashboard'); + + logger.info('Press Ctrl+C to exit'); + + // Wait for shutdown + final completer = Completer(); + + // Set up signal handlers + ProcessSignal.sigint.watch().listen((_) async { + logger.info('Shutting down Mumble Music Bot'); + + // Stop music playback + try { + await audioStreamer.stopStreaming(); + musicQueue.dispose(); + } catch (e) { + logger.warning('Error stopping audio services: $e'); + } + + // Disconnect from Mumble server + if (mumbleConnection.isConnected) { + try { + await mumbleConnection.sendChannelMessage('MumBullet Music Bot is shutting down...'); + await mumbleConnection.disconnect(); + } catch (e) { + logger.warning('Error during shutdown: $e'); + } + } + + // Dispose resources + mumbleConnection.dispose(); + messageHandler.dispose(); + database.close(); + + completer.complete(); + }); + + ProcessSignal.sigterm.watch().listen((_) async { + logger.info('Shutting down Mumble Music Bot'); + + // Stop music playback + try { + await audioStreamer.stopStreaming(); + musicQueue.dispose(); + } catch (e) { + logger.warning('Error stopping audio services: $e'); + } + + // Disconnect from Mumble server + if (mumbleConnection.isConnected) { + try { + await mumbleConnection.sendChannelMessage('MumBullet Music Bot is shutting down...'); + await mumbleConnection.disconnect(); + } catch (e) { + logger.warning('Error during shutdown: $e'); + } + } + + // Dispose resources + mumbleConnection.dispose(); + messageHandler.dispose(); + database.close(); + + completer.complete(); + }); + + await completer.future; + + // Close logger + logManager.close(); + exit(0); + + } catch (e, stackTrace) { + print('Error: $e'); + print('Stack trace: $stackTrace'); + print(''); + print('Usage:'); + print(parser.usage); + exit(1); + } +} + +Level _parseLogLevel(String level) { + switch (level.toLowerCase()) { + case 'debug': + return Level.FINE; + case 'info': + return Level.INFO; + case 'warning': + return Level.WARNING; + case 'error': + return Level.SEVERE; + default: + return Level.INFO; + } } diff --git a/build.yaml b/build.yaml new file mode 100644 index 0000000..c868b38 --- /dev/null +++ b/build.yaml @@ -0,0 +1,15 @@ +targets: + $default: + builders: + json_serializable: + options: + any_map: false + checked: false + create_factory: true + create_to_json: true + disallow_unrecognized_keys: false + explicit_to_json: true + field_rename: snake + generic_argument_factories: false + ignore_unannotated: false + include_if_null: true \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..8bcd710 --- /dev/null +++ b/config.json @@ -0,0 +1,21 @@ +{ + "mumble": { + "server": "localhost", + "port": 64738, + "username": "MusicBot", + "password": "testpass", + "channel": "Music" + }, + "bot": { + "command_prefix": "!", + "default_permission_level": 1, + "max_queue_size": 50, + "cache_directory": "./cache", + "max_cache_size_gb": 5 + }, + "dashboard": { + "port": 8080, + "admin_username": "admin", + "admin_password": "changeme" + } +} diff --git a/dart_test.yaml b/dart_test.yaml new file mode 100644 index 0000000..02f40e1 --- /dev/null +++ b/dart_test.yaml @@ -0,0 +1,20 @@ +tags: + # Define test categories + unit: + timeout: 30s + integration: + timeout: 60s + docker: + timeout: 60s + +# Configure test directories +paths: + - test/unit + - test/integration + +# Test concurrency settings +concurrency: 1 +timeout: 30s + +# Reporter configuration +reporter: expanded \ No newline at end of file diff --git a/dev-compose.yml b/dev-compose.yml new file mode 100644 index 0000000..d056b61 --- /dev/null +++ b/dev-compose.yml @@ -0,0 +1,39 @@ +version: '3' + +services: + mumble: + image: mumblevoip/mumble-server:latest + container_name: mumble-dev-server + restart: on-failure + ports: + - "64738:64738/udp" # Mumble voice port + - "64738:64738" # Web interface/WebRTC port (optional) + environment: + # Basic server settings + - MUMBLE_SERVER_WELCOME_TEXT=Welcome to the MumBullet Development Server
This server is for development and testing. + - MUMBLE_SERVER_NAME=MumBullet Dev Server + - MUMBLE_LOG_LEVEL=info + - MUMBLE_SERVER_PASSWORD=devpass + + # Superuser account + - SUPERUSER_PASSWORD=devsecret + + # Channel setup (format: parent/child/grandchild) + - MUMBLE_CHANNELS=Music,General,Gaming,Music/Subroom1,Music/Subroom2,General/Subroom3 + + # Create registered users - NOTE: User registration must be enabled + - MUMBLE_SERVER_REGISTER_SELF=1 + - MUMBLE_SERVER_REGISTER_REQUIRED=0 + + # Server settings + - MUMBLE_SERVER_BANDWIDTH=128000 + - MUMBLE_SERVER_USERS=100 + - MUMBLE_SERVER_ALLOW_HTML=1 + - MUMBLE_SERVER_MESSAGE_LENGTH=5000 + - MUMBLE_SERVER_DEFAULT_CHANNEL=Music + volumes: + - mumble-dev-data:/var/lib/mumble-server + +volumes: + mumble-dev-data: + driver: local \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c9f8a2a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + mumble: + image: mumblevoip/mumble-server:latest + container_name: mumble-server + restart: on-failure + ports: + - "64738:64738/udp" # Mumble voice port + - "64738:64738" # Web interface/WebRTC port (optional) + environment: + # Basic server settings + - MUMBLE_SERVER_WELCOME_TEXT=Welcome to the MumBullet Test Server
This server is used for integration testing. + - MUMBLE_SERVER_NAME=MumBullet Test Server + - MUMBLE_LOG_LEVEL=info + - MUMBLE_SERVER_PASSWORD=serverpassword + + # Superuser account + - SUPERUSER_PASSWORD=supersecret + + # Channel setup (format: parent/child/grandchild) + - MUMBLE_CHANNELS=Music,General,Gaming,Music/Subroom1,Music/Subroom2,General/Subroom3 + + # Create registered users - NOTE: User registration must be enabled + - MUMBLE_SERVER_REGISTER_SELF=1 + - MUMBLE_SERVER_REGISTER_REQUIRED=0 + + # Server settings + - MUMBLE_SERVER_BANDWIDTH=128000 + - MUMBLE_SERVER_USERS=100 + - MUMBLE_SERVER_ALLOW_HTML=1 + - MUMBLE_SERVER_MESSAGE_LENGTH=5000 diff --git a/flake.nix b/flake.nix index 40fb1d0..e8372cf 100644 --- a/flake.nix +++ b/flake.nix @@ -21,6 +21,9 @@ buildInputs = with pkgs; [ flutter dart + yt-dlp + ffmpeg + libopus clang cmake ninja diff --git a/lib/mumbullet.dart b/lib/mumbullet.dart index f64ad72..319687e 100644 --- a/lib/mumbullet.dart +++ b/lib/mumbullet.dart @@ -1,3 +1,16 @@ -int calculate() { - return 6 * 7; -} +/// Mumble music bot library +library mumbullet; + +export 'src/audio/converter.dart'; +export 'src/audio/downloader.dart'; +export 'src/command/command_handler.dart'; +export 'src/command/command_parser.dart'; +export 'src/config/config.dart'; +export 'src/logging/logger.dart'; +export 'src/mumble/audio_streamer.dart'; +export 'src/mumble/connection.dart'; +export 'src/mumble/message_handler.dart'; +export 'src/mumble/models.dart'; +export 'src/queue/music_queue.dart'; +export 'src/storage/database.dart'; +export 'src/storage/permission_manager.dart'; diff --git a/lib/src/audio/converter.dart b/lib/src/audio/converter.dart new file mode 100644 index 0000000..c4bbebf --- /dev/null +++ b/lib/src/audio/converter.dart @@ -0,0 +1,114 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:mumbullet/src/logging/logger.dart'; +import 'package:path/path.dart' as path; + +/// Class for converting audio to Mumble-compatible format +class AudioConverter { + static const int _sampleRate = 48000; // Hz + static const int _bitDepth = 16; // bits + static const int _channels = 1; // mono + + final LogManager _logManager; + + /// Create a new audio converter + AudioConverter() : _logManager = LogManager.getInstance(); + + /// Convert an audio file to PCM WAV format compatible with Mumble + Future convertToPcm(String inputPath) async { + _logManager.info('Converting audio file to PCM: $inputPath'); + + try { + // Generate output path + final dir = path.dirname(inputPath); + final baseName = path.basenameWithoutExtension(inputPath); + final outputPath = path.join(dir, '${baseName}_pcm.wav'); + + // Build FFmpeg command + final args = [ + '-i', inputPath, + '-acodec', 'pcm_s16le', + '-ac', '$_channels', + '-ar', '$_sampleRate', + '-f', 'wav', + outputPath, + ]; + + _logManager.debug('Running FFmpeg with args: ${args.join(' ')}'); + + // Run FFmpeg + final result = await Process.run('ffmpeg', args); + + if (result.exitCode != 0) { + throw Exception('FFmpeg conversion failed: ${result.stderr}'); + } + + _logManager.info('Converted audio file to PCM: $outputPath'); + return outputPath; + } catch (e, stackTrace) { + _logManager.error('Error converting audio file', e, stackTrace); + rethrow; + } + } + + /// Read PCM data from a WAV file + Future readPcmData(String filePath, [int maxBytes = 0]) async { + _logManager.debug('Reading PCM data from: $filePath'); + + try { + final file = File(filePath); + + // Read the file + final bytes = await file.readAsBytes(); + + // Parse WAV header (simplified) + // WAV header is 44 bytes, but we're going to skip checking format details + const headerSize = 44; + + if (bytes.length <= headerSize) { + throw Exception('Invalid WAV file: too small'); + } + + // Extract PCM data (skip header) + final pcmData = bytes.sublist(headerSize); + + // Limit bytes if specified + if (maxBytes > 0 && pcmData.length > maxBytes) { + return Uint8List.fromList(pcmData.sublist(0, maxBytes)); + } + + return Uint8List.fromList(pcmData); + } catch (e, stackTrace) { + _logManager.error('Error reading PCM data', e, stackTrace); + rethrow; + } + } + + /// Convert a file to streamable chunks + Future> convertToStreamableChunks(String filePath, int chunkSizeBytes) async { + _logManager.debug('Converting to streamable chunks: $filePath'); + + try { + // First convert to PCM if needed + final extension = path.extension(filePath).toLowerCase(); + final pcmPath = extension == '.wav' ? filePath : await convertToPcm(filePath); + + // Read the PCM data + final pcmData = await readPcmData(pcmPath); + + // Split into chunks + final chunks = []; + for (var i = 0; i < pcmData.length; i += chunkSizeBytes) { + final end = (i + chunkSizeBytes < pcmData.length) ? i + chunkSizeBytes : pcmData.length; + chunks.add(Uint8List.fromList(pcmData.sublist(i, end))); + } + + _logManager.debug('Created ${chunks.length} audio chunks of approximately $chunkSizeBytes bytes each'); + return chunks; + } catch (e, stackTrace) { + _logManager.error('Error creating streamable chunks', e, stackTrace); + rethrow; + } + } +} \ No newline at end of file diff --git a/lib/src/audio/downloader.dart b/lib/src/audio/downloader.dart new file mode 100644 index 0000000..c755624 --- /dev/null +++ b/lib/src/audio/downloader.dart @@ -0,0 +1,311 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:mumbullet/src/config/config.dart'; +import 'package:mumbullet/src/logging/logger.dart'; +import 'package:mumbullet/src/storage/database.dart'; +import 'package:path/path.dart' as path; + +/// Represents a song with metadata +class Song { + final int id; + final String url; + final String filePath; + final String title; + final int duration; + final DateTime addedAt; + + Song({ + required this.id, + required this.url, + required this.filePath, + required this.title, + required this.duration, + required this.addedAt, + }); + + @override + String toString() => 'Song(id: $id, title: $title, duration: ${duration}s)'; +} + +/// Class for downloading audio from YouTube +class YoutubeDownloader { + final BotConfig _config; + final DatabaseManager _db; + final LogManager _logManager; + + final String _cacheDir; + + /// Create a new YouTube downloader + YoutubeDownloader(this._config, this._db) + : _logManager = LogManager.getInstance(), + _cacheDir = _config.cacheDirectory { + _initializeCache(); + } + + /// Initialize the cache directory + void _initializeCache() { + final dir = Directory(_cacheDir); + if (!dir.existsSync()) { + dir.createSync(recursive: true); + _logManager.info('Created cache directory: $_cacheDir'); + } + } + + /// Download audio from a URL + Future download(String url) async { + _logManager.info('Downloading audio from URL: $url'); + + // Check if URL is already in cache + final cachedSong = await _db.cache.getSongByUrl(url); + if (cachedSong != null) { + final file = File(cachedSong.filePath); + if (file.existsSync()) { + // Update last accessed time + await _db.cache.updateSongLastAccessed(cachedSong.id); + + _logManager.info('Using cached audio for URL: $url'); + return cachedSong; + } else { + // File was deleted, remove from cache + _logManager.warning('Cached file not found, re-downloading: $url'); + await _db.cache.removeSong(cachedSong.id); + } + } + + try { + // Generate a unique file name + final fileName = '${_generateRandomString(10)}.wav'; + final outputPath = path.join(_cacheDir, fileName); + + // Use yt-dlp to download audio + final result = await _runYtDlp(url, outputPath); + + if (result.exitCode != 0) { + throw Exception('Failed to download audio: ${result.stderr}'); + } + + // Extract metadata from files + final outputPathWithoutExt = outputPath.replaceAll('.wav', ''); + final title = await _extractTitleFromFile('$outputPathWithoutExt.title'); + final duration = await _extractDurationFromFile('$outputPathWithoutExt.duration'); + + // Add to cache database + final song = await _db.cache.addSong( + url: url, + filePath: outputPath, + title: title, + duration: duration, + ); + + _logManager.info('Downloaded audio: $title ($duration seconds)'); + + // Check if cache size limit is reached + await _enforeCacheSizeLimit(); + + return song; + } catch (e, stackTrace) { + _logManager.error('Error downloading audio from URL: $url', e, stackTrace); + rethrow; + } + } + + /// Run yt-dlp to download audio + Future _runYtDlp(String url, String outputPath) async { + // yt-dlp needs the output path without extension when extracting audio + final outputPathWithoutExt = outputPath.replaceAll('.wav', ''); + + final args = [ + url, + '-x', // Extract audio + '--audio-format', 'wav', // Convert to WAV + '--audio-quality', '0', // Best quality + '-o', '$outputPathWithoutExt.%(ext)s', // Output file template + '--no-playlist', // Don't download playlists + '--print-to-file', 'title', '$outputPathWithoutExt.title', // Save title to file + '--print-to-file', 'duration', '$outputPathWithoutExt.duration', // Save duration to file + ]; + + _logManager.debug('Running yt-dlp with args: ${args.join(' ')}'); + + return Process.run('yt-dlp', args); + } + + /// Extract title from metadata file + Future _extractTitleFromFile(String filePath) async { + try { + final file = File(filePath); + if (await file.exists()) { + final content = await file.readAsString(); + final title = content.trim(); + await file.delete(); // Clean up metadata file + return title.isNotEmpty ? title : 'Unknown Title'; + } + } catch (e) { + _logManager.warning('Failed to read title from file: $filePath', e); + } + return 'Unknown Title'; + } + + /// Extract duration from metadata file + Future _extractDurationFromFile(String filePath) async { + try { + final file = File(filePath); + if (await file.exists()) { + final content = await file.readAsString(); + final durationStr = content.trim(); + await file.delete(); // Clean up metadata file + return int.tryParse(durationStr) ?? 0; + } + } catch (e) { + _logManager.warning('Failed to read duration from file: $filePath', e); + } + return 0; + } + + /// Extract title from yt-dlp output (fallback method) + String _extractTitle(String output) { + final lines = output.split('\n'); + return lines.isNotEmpty ? lines.first.trim() : 'Unknown Title'; + } + + /// Extract duration from yt-dlp output (fallback method) + int _extractDuration(String output) { + final lines = output.split('\n'); + if (lines.length >= 2) { + final durationStr = lines[1].trim(); + return int.tryParse(durationStr) ?? 0; + } + return 0; + } + + /// Generate a random string of specified length + String _generateRandomString(int length) { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + final random = Random(); + return String.fromCharCodes( + Iterable.generate( + length, + (_) => chars.codeUnitAt(random.nextInt(chars.length)), + ), + ); + } + + /// Enforce cache size limit + Future _enforeCacheSizeLimit() async { + final maxSizeBytes = (_config.maxCacheSizeGb * 1024 * 1024 * 1024).toInt(); + + try { + // Get current cache size + final cacheDir = Directory(_cacheDir); + final files = cacheDir.listSync(); + + int totalSize = 0; + for (final file in files) { + if (file is File) { + totalSize += file.lengthSync(); + } + } + + _logManager.debug('Current cache size: ${totalSize ~/ (1024 * 1024)} MB'); + + // If cache size exceeds limit, remove oldest files + if (totalSize > maxSizeBytes) { + _logManager.info( + 'Cache size exceeds limit (${totalSize ~/ (1024 * 1024)} MB > ' + '${maxSizeBytes ~/ (1024 * 1024)} MB), removing oldest files' + ); + + // Get songs sorted by last accessed time + final songs = await _db.cache.getAllSongs(); + songs.sort((a, b) => a.addedAt.compareTo(b.addedAt)); + + // Remove songs until cache size is below limit + for (final song in songs) { + final file = File(song.filePath); + + if (file.existsSync()) { + final fileSize = file.lengthSync(); + await file.delete(); + await _db.cache.removeSong(song.id); + + totalSize -= fileSize; + _logManager.debug('Removed from cache: ${song.title}'); + + if (totalSize <= maxSizeBytes) { + break; + } + } + } + } + } catch (e, stackTrace) { + _logManager.error('Error enforcing cache size limit', e, stackTrace); + } + } + + /// Get all cached songs + Future> getCachedSongs() async { + return _db.cache.getAllSongs(); + } + + /// Clear the entire cache + Future clearCache() async { + try { + _logManager.info('Clearing audio cache'); + + // Delete all files + final cacheDir = Directory(_cacheDir); + if (cacheDir.existsSync()) { + final files = cacheDir.listSync(); + for (final file in files) { + if (file is File) { + await file.delete(); + } + } + } + + // Clear database + await _db.cache.clearAllSongs(); + + _logManager.info('Audio cache cleared'); + } catch (e, stackTrace) { + _logManager.error('Error clearing audio cache', e, stackTrace); + rethrow; + } + } + + /// Get cache statistics + Future> getCacheStats() async { + try { + final songs = await _db.cache.getAllSongs(); + final cacheDir = Directory(_cacheDir); + + int totalSize = 0; + if (cacheDir.existsSync()) { + final files = cacheDir.listSync(); + for (final file in files) { + if (file is File) { + totalSize += file.lengthSync(); + } + } + } + + return { + 'songCount': songs.length, + 'totalSizeBytes': totalSize, + 'totalSizeMb': totalSize ~/ (1024 * 1024), + 'maxSizeMb': (_config.maxCacheSizeGb * 1024).toInt(), + }; + } catch (e, stackTrace) { + _logManager.error('Error getting cache statistics', e, stackTrace); + return { + 'songCount': 0, + 'totalSizeBytes': 0, + 'totalSizeMb': 0, + 'maxSizeMb': (_config.maxCacheSizeGb * 1024).toInt(), + }; + } + } +} diff --git a/lib/src/audio/opus_encoder.dart b/lib/src/audio/opus_encoder.dart new file mode 100644 index 0000000..b60c7ac --- /dev/null +++ b/lib/src/audio/opus_encoder.dart @@ -0,0 +1,130 @@ +import 'dart:ffi'; +import 'dart:typed_data'; + +import 'package:mumbullet/src/logging/logger.dart'; +import 'package:opus_dart/opus_dart.dart'; + +/// Wrapper class for Opus audio encoding +class OpusAudioEncoder { + static const int _sampleRate = 48000; // Hz + static const int _frameSize = 960; // samples per frame (20ms at 48kHz) + static const int _channels = 1; // mono + static const Application _application = Application.audio; + + final LogManager _logManager; + SimpleOpusEncoder? _encoder; + bool _isInitialized = false; + + /// Create a new Opus encoder + OpusAudioEncoder() : _logManager = LogManager.getInstance(); + + /// Initialize the Opus encoder + Future initialize() async { + if (_isInitialized) { + return; + } + + try { + _logManager.info('Initializing Opus encoder'); + + // Initialize the opus library with the dynamic library + final lib = _loadOpusLibrary(); + initOpus(lib); + + _logManager.info('Opus library version: ${getOpusVersion()}'); + + // Create the Opus encoder + _encoder = SimpleOpusEncoder( + sampleRate: _sampleRate, + channels: _channels, + application: _application, + ); + + _isInitialized = true; + _logManager.info('Opus encoder initialized successfully'); + } catch (e, stackTrace) { + _logManager.error('Failed to initialize Opus encoder', e, stackTrace); + rethrow; + } + } + + /// Load the opus dynamic library + DynamicLibrary _loadOpusLibrary() { + // Try different possible locations for libopus + final possiblePaths = [ + '/nix/store/ggkxbnsjmq4llprwa51jab5m5cw7jc8r-libopus-1.5.2/lib/libopus.so', + 'libopus.so.0', + 'libopus.so', + '/usr/lib/libopus.so.0', + '/usr/lib/libopus.so', + '/usr/local/lib/libopus.so.0', + '/usr/local/lib/libopus.so', + ]; + + for (final path in possiblePaths) { + try { + final lib = DynamicLibrary.open(path); + _logManager.info('Successfully loaded libopus from: $path'); + return lib; + } catch (e) { + _logManager.debug('Failed to load libopus from $path: $e'); + continue; + } + } + + throw Exception('Could not find libopus dynamic library. Please ensure libopus is installed.'); + } + + /// Encode PCM audio data to Opus + Uint8List encode(Uint8List pcmData) { + if (!_isInitialized || _encoder == null) { + throw StateError('Opus encoder not initialized'); + } + + try { + // Convert bytes to 16-bit signed integers + final samples = Int16List.view(pcmData.buffer); + + // Ensure we have exactly the right number of samples for a frame + if (samples.length != _frameSize * _channels) { + throw ArgumentError( + 'Invalid frame size: expected ${_frameSize * _channels} samples, got ${samples.length}' + ); + } + + // Encode the frame + final encoded = _encoder!.encode(input: samples); + + _logManager.debug('Encoded ${pcmData.length} bytes of PCM to ${encoded.length} bytes of Opus'); + return encoded; + } catch (e, stackTrace) { + _logManager.error('Failed to encode audio frame', e, stackTrace); + rethrow; + } + } + + /// Get the frame size in samples + int get frameSize => _frameSize; + + /// Get the frame size in bytes (for PCM input) + int get frameSizeBytes => _frameSize * _channels * 2; // 16-bit = 2 bytes per sample + + /// Get the sample rate + int get sampleRate => _sampleRate; + + /// Get the number of channels + int get channels => _channels; + + /// Check if the encoder is initialized + bool get isInitialized => _isInitialized; + + /// Dispose the encoder + void dispose() { + if (_isInitialized && _encoder != null) { + _logManager.info('Disposing Opus encoder'); + _encoder!.destroy(); + _encoder = null; + _isInitialized = false; + } + } +} diff --git a/lib/src/command/command_handler.dart b/lib/src/command/command_handler.dart new file mode 100644 index 0000000..f35b7ed --- /dev/null +++ b/lib/src/command/command_handler.dart @@ -0,0 +1,271 @@ +import 'dart:async'; + +import 'package:dumble/dumble.dart'; +import 'package:mumbullet/src/audio/downloader.dart'; +import 'package:mumbullet/src/command/command_parser.dart'; +import 'package:mumbullet/src/config/config.dart'; +import 'package:mumbullet/src/logging/logger.dart'; +import 'package:mumbullet/src/mumble/message_handler.dart'; +import 'package:mumbullet/src/queue/music_queue.dart'; + +/// Class for handling commands +class CommandHandler { + final CommandParser _commandParser; + final MusicQueue _musicQueue; + final YoutubeDownloader _downloader; + final BotConfig _config; + final LogManager _logManager; + + /// Create a new command handler + CommandHandler( + this._commandParser, + this._musicQueue, + this._downloader, + this._config, + ) : _logManager = LogManager.getInstance() { + _registerCommands(); + } + + /// Register all available commands + void _registerCommands() { + // Help command + _commandParser.registerCommand(Command( + name: 'help', + description: 'Show available commands', + usage: 'help', + requiredPermissionLevel: 0, + execute: _handleHelpCommand, + )); + + // Play command + _commandParser.registerCommand(Command( + name: 'play', + description: 'Clear queue and play a song immediately', + usage: 'play ', + requiredPermissionLevel: 2, + requiresArgs: true, + execute: _handlePlayCommand, + )); + + // Queue command + _commandParser.registerCommand(Command( + name: 'queue', + description: 'Add a song to the queue', + usage: 'queue ', + requiredPermissionLevel: 1, + requiresArgs: true, + execute: _handleQueueCommand, + )); + + // Skip command + _commandParser.registerCommand(Command( + name: 'skip', + description: 'Skip the current song', + usage: 'skip', + requiredPermissionLevel: 2, + execute: _handleSkipCommand, + )); + + // List command + _commandParser.registerCommand(Command( + name: 'list', + description: 'Show the current queue', + usage: 'list', + requiredPermissionLevel: 0, + execute: _handleListCommand, + )); + + // Clear command + _commandParser.registerCommand(Command( + name: 'clear', + description: 'Clear the queue', + usage: 'clear', + requiredPermissionLevel: 2, + execute: _handleClearCommand, + )); + + // Shuffle command + _commandParser.registerCommand(Command( + name: 'shuffle', + description: 'Shuffle the downloaded songs and start playing', + usage: 'shuffle', + requiredPermissionLevel: 2, + execute: _handleShuffleCommand, + )); + } + + /// Handle the help command + Future _handleHelpCommand(CommandContext context) async { + final permissionLevel = context.permissionLevel; + final commands = _commandParser.getCommandsForPermissionLevel(permissionLevel); + + final helpText = StringBuffer(); + helpText.writeln('Available commands:'); + + for (final command in commands) { + helpText.writeln('${_config.commandPrefix}${command.usage} - ${command.description}'); + } + + await context.reply(helpText.toString()); + } + + /// Handle the play command + Future _handlePlayCommand(CommandContext context) async { + final url = _extractUrlFromHtml(context.args); + + _logManager.info('Downloading audio from URL: $url'); + await context.reply('Downloading audio from: $url'); + + try { + // Clear the queue + _musicQueue.clear(); + + // Download and enqueue + final song = await _downloader.download(url); + + // Add to queue and play immediately + _musicQueue.enqueue(song, true); + + await context.reply('Now playing: ${song.title}'); + } catch (e) { + _logManager.error('Failed to play URL: $url', e); + await context.reply('Failed to download or play the audio: ${e.toString()}'); + } + } + + /// Handle the queue command + Future _handleQueueCommand(CommandContext context) async { + final url = _extractUrlFromHtml(context.args); + + _logManager.info('Downloading audio from URL: $url'); + await context.reply('Adding to queue: $url'); + + try { + // Download and enqueue + final song = await _downloader.download(url); + + // Add to queue + final position = _musicQueue.enqueue(song); + + if (position == 0 && _musicQueue.currentSong == song) { + await context.reply('Now playing: ${song.title}'); + } else { + await context.reply('Added to queue at position $position: ${song.title}'); + } + } catch (e) { + _logManager.error('Failed to queue URL: $url', e); + await context.reply('Failed to download or queue the audio: ${e.toString()}'); + } + } + + /// Handle the skip command + Future _handleSkipCommand(CommandContext context) async { + final currentSong = _musicQueue.currentSong; + + if (currentSong == null) { + await context.reply('No song is currently playing.'); + return; + } + + final skippedTitle = currentSong.title; + + if (_musicQueue.skip()) { + final nextSong = _musicQueue.currentSong; + + if (nextSong != null) { + await context.reply('Skipped: $skippedTitle. Now playing: ${nextSong.title}'); + } else { + await context.reply('Skipped: $skippedTitle. Queue is now empty.'); + } + } else { + await context.reply('Failed to skip the current song.'); + } + } + + /// Handle the list command + Future _handleListCommand(CommandContext context) async { + final queue = _musicQueue.getQueue(); + final currentSong = _musicQueue.currentSong; + + if (queue.isEmpty) { + await context.reply('Queue is empty.'); + return; + } + + final queueText = StringBuffer(); + queueText.writeln('Queue (${queue.length} songs):'); + + for (var i = 0; i < queue.length; i++) { + final song = queue[i]; + final prefix = (song == currentSong) ? '▶️ ' : '${i + 1}. '; + queueText.writeln('$prefix${song.title} (${_formatDuration(song.duration)})'); + } + + await context.reply(queueText.toString()); + } + + /// Handle the clear command + Future _handleClearCommand(CommandContext context) async { + _musicQueue.clear(); + await context.reply('Queue cleared.'); + } + + /// Handle the shuffle command + Future _handleShuffleCommand(CommandContext context) async { + try { + final cachedSongs = await _downloader.getCachedSongs(); + + if (cachedSongs.isEmpty) { + await context.reply('No cached songs found. Queue some songs first.'); + return; + } + + // Clear the queue and add shuffled songs + _musicQueue.clear(); + + // Shuffle the songs + cachedSongs.shuffle(); + + // Take up to max queue size + final songsToAdd = cachedSongs.take(_config.maxQueueSize).toList(); + + // Add to queue + for (final song in songsToAdd) { + _musicQueue.enqueue(song, song == songsToAdd.first); + } + + await context.reply( + 'Added ${songsToAdd.length} shuffled songs to the queue. ' + 'Now playing: ${songsToAdd.first.title}' + ); + } catch (e) { + _logManager.error('Failed to shuffle songs', e); + await context.reply('Failed to shuffle songs: ${e.toString()}'); + } + } + + /// Extract URL from HTML tag or return the original string if not an HTML link + String _extractUrlFromHtml(String input) { + final trimmed = input.trim(); + + // Check if the input looks like an HTML tag + final aTagRegex = RegExp(r']*>.*?', caseSensitive: false); + final match = aTagRegex.firstMatch(trimmed); + + if (match != null) { + final url = match.group(1)!; + _logManager.debug('Extracted URL from HTML: $url (original: $trimmed)'); + return url; + } + + // If it's not an HTML link, return the original input + return trimmed; + } + + /// Format a duration in seconds as mm:ss + String _formatDuration(int seconds) { + final mins = seconds ~/ 60; + final secs = seconds % 60; + return '$mins:${secs.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/src/command/command_parser.dart b/lib/src/command/command_parser.dart new file mode 100644 index 0000000..c98118a --- /dev/null +++ b/lib/src/command/command_parser.dart @@ -0,0 +1,185 @@ +import 'dart:async'; + +import 'package:mumbullet/src/config/config.dart'; +import 'package:mumbullet/src/logging/logger.dart'; +import 'package:mumbullet/src/mumble/message_handler.dart'; +import 'package:mumbullet/src/mumble/models.dart'; + +/// Definition of a command +class Command { + final String name; + final String description; + final String usage; + final int requiredPermissionLevel; + final bool requiresArgs; + final Future Function(CommandContext) execute; + + Command({ + required this.name, + required this.description, + required this.usage, + required this.execute, + this.requiredPermissionLevel = 0, + this.requiresArgs = false, + }); +} + +/// Context for command execution +class CommandContext { + final String commandName; + final String args; + final MumbleUser sender; + final bool isPrivate; + final int permissionLevel; + + /// Function to reply to the sender + final Future Function(String) reply; + + CommandContext({ + required this.commandName, + required this.args, + required this.sender, + required this.isPrivate, + required this.permissionLevel, + required this.reply, + }); +} + +/// Result of a command execution +enum CommandResult { + success, + notFound, + noPermission, + invalidArguments, + error, +} + +/// Class for parsing and executing commands +class CommandParser { + final MumbleMessageHandler _messageHandler; + final BotConfig _config; + final LogManager _logManager; + + /// Map of command name to command + final Map _commands = {}; + + /// Function to get permission level + final Future Function(MumbleUser) _getPermissionLevel; + + /// Create a new command parser + CommandParser( + this._messageHandler, + this._config, + this._getPermissionLevel, + ) : _logManager = LogManager.getInstance() { + _setupCommandListener(); + } + + /// Register a command + void registerCommand(Command command) { + _commands[command.name] = command; + _logManager.debug('Registered command: ${command.name}'); + } + + /// Set up the command listener + void _setupCommandListener() { + _messageHandler.commandStream.listen((data) async { + final command = data['command'] as String; + final args = data['args'] as String; + final sender = data['sender'] as MumbleUser; + final isPrivate = data['isPrivate'] as bool; + + await _handleCommand(command, args, sender, isPrivate); + }); + } + + /// Handle a command + Future _handleCommand( + String command, + String args, + MumbleUser sender, + bool isPrivate, + ) async { + _logManager.info('Handling command: $command, args: $args, from: ${sender.name}'); + + // Check if command exists + if (!_commands.containsKey(command)) { + _logManager.warning('Command not found: $command'); + await _messageHandler.replyToMessage( + 'Unknown command: $command. Type ${_config.commandPrefix}help for a list of commands.', + sender, + isPrivate, + ); + return CommandResult.notFound; + } + + final cmd = _commands[command]!; + + // Check if user has permission + final permissionLevel = await _getPermissionLevel(sender); + if (permissionLevel < cmd.requiredPermissionLevel) { + _logManager.warning( + 'User ${sender.name} does not have permission to use command: $command ' + '(has: $permissionLevel, required: ${cmd.requiredPermissionLevel})' + ); + await _messageHandler.replyToMessage( + 'You do not have permission to use this command.', + sender, + isPrivate, + ); + return CommandResult.noPermission; + } + + // Check if command requires arguments + if (cmd.requiresArgs && args.trim().isEmpty) { + _logManager.warning('Command $command requires arguments, but none provided'); + await _messageHandler.replyToMessage( + 'Usage: ${_config.commandPrefix}${cmd.usage}', + sender, + isPrivate, + ); + return CommandResult.invalidArguments; + } + + // Create a reply function + final reply = (String message) { + return _messageHandler.replyToMessage(message, sender, isPrivate); + }; + + // Create command context + final context = CommandContext( + commandName: command, + args: args, + sender: sender, + isPrivate: isPrivate, + permissionLevel: permissionLevel, + reply: reply, + ); + + // Execute the command + try { + await cmd.execute(context); + return CommandResult.success; + } catch (e, stackTrace) { + _logManager.error('Error executing command: $command', e, stackTrace); + await _messageHandler.replyToMessage( + 'An error occurred while executing the command.', + sender, + isPrivate, + ); + return CommandResult.error; + } + } + + /// Get a list of all commands + List getCommands() { + return _commands.values.toList(); + } + + /// Get commands filtered by permission level + List getCommandsForPermissionLevel(int permissionLevel) { + return _commands.values + .where((cmd) => cmd.requiredPermissionLevel <= permissionLevel) + .toList(); + } +} \ No newline at end of file diff --git a/lib/src/config/config.dart b/lib/src/config/config.dart new file mode 100644 index 0000000..31da2df --- /dev/null +++ b/lib/src/config/config.dart @@ -0,0 +1,119 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:json_annotation/json_annotation.dart'; + +part 'config.g.dart'; + +@JsonSerializable(explicitToJson: true) +class MumbleConfig { + final String server; + final int port; + final String username; + final String password; + final String channel; + + MumbleConfig({ + required this.server, + required this.port, + required this.username, + required this.password, + required this.channel, + }); + + factory MumbleConfig.fromJson(Map json) => + _$MumbleConfigFromJson(json); + Map toJson() => _$MumbleConfigToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class BotConfig { + final String commandPrefix; + final int defaultPermissionLevel; + final int maxQueueSize; + final String cacheDirectory; + final double maxCacheSizeGb; + + BotConfig({ + required this.commandPrefix, + required this.defaultPermissionLevel, + required this.maxQueueSize, + required this.cacheDirectory, + required this.maxCacheSizeGb, + }); + + factory BotConfig.fromJson(Map json) => + _$BotConfigFromJson(json); + Map toJson() => _$BotConfigToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class DashboardConfig { + final int port; + final String adminUsername; + final String adminPassword; + + DashboardConfig({ + required this.port, + required this.adminUsername, + required this.adminPassword, + }); + + factory DashboardConfig.fromJson(Map json) => + _$DashboardConfigFromJson(json); + Map toJson() => _$DashboardConfigToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class AppConfig { + final MumbleConfig mumble; + final BotConfig bot; + final DashboardConfig dashboard; + + AppConfig({ + required this.mumble, + required this.bot, + required this.dashboard, + }); + + factory AppConfig.fromJson(Map json) => + _$AppConfigFromJson(json); + Map toJson() => _$AppConfigToJson(this); + + factory AppConfig.fromFile(String filePath) { + final file = File(filePath); + if (!file.existsSync()) { + throw FileSystemException('Configuration file not found', filePath); + } + + final jsonString = file.readAsStringSync(); + final jsonMap = json.decode(jsonString) as Map; + + return AppConfig.fromJson(jsonMap); + } + + void validate() { + // Validate required fields + if (mumble.server.isEmpty) { + throw ArgumentError('Mumble server cannot be empty'); + } + if (mumble.username.isEmpty) { + throw ArgumentError('Mumble username cannot be empty'); + } + if (bot.commandPrefix.isEmpty) { + throw ArgumentError('Command prefix cannot be empty'); + } + if (bot.maxQueueSize <= 0) { + throw ArgumentError('Max queue size must be greater than 0'); + } + if (bot.maxCacheSizeGb <= 0) { + throw ArgumentError('Max cache size must be greater than 0'); + } + if (dashboard.adminUsername.isEmpty) { + throw ArgumentError('Admin username cannot be empty'); + } + if (dashboard.adminPassword.isEmpty) { + throw ArgumentError('Admin password cannot be empty'); + } + } +} \ No newline at end of file diff --git a/lib/src/config/config.g.dart b/lib/src/config/config.g.dart new file mode 100644 index 0000000..733a3d7 --- /dev/null +++ b/lib/src/config/config.g.dart @@ -0,0 +1,68 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'config.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +MumbleConfig _$MumbleConfigFromJson(Map json) => MumbleConfig( + server: json['server'] as String, + port: (json['port'] as num).toInt(), + username: json['username'] as String, + password: json['password'] as String, + channel: json['channel'] as String, +); + +Map _$MumbleConfigToJson(MumbleConfig instance) => + { + 'server': instance.server, + 'port': instance.port, + 'username': instance.username, + 'password': instance.password, + 'channel': instance.channel, + }; + +BotConfig _$BotConfigFromJson(Map json) => BotConfig( + commandPrefix: json['command_prefix'] as String, + defaultPermissionLevel: (json['default_permission_level'] as num).toInt(), + maxQueueSize: (json['max_queue_size'] as num).toInt(), + cacheDirectory: json['cache_directory'] as String, + maxCacheSizeGb: (json['max_cache_size_gb'] as num).toDouble(), +); + +Map _$BotConfigToJson(BotConfig instance) => { + 'command_prefix': instance.commandPrefix, + 'default_permission_level': instance.defaultPermissionLevel, + 'max_queue_size': instance.maxQueueSize, + 'cache_directory': instance.cacheDirectory, + 'max_cache_size_gb': instance.maxCacheSizeGb, +}; + +DashboardConfig _$DashboardConfigFromJson(Map json) => + DashboardConfig( + port: (json['port'] as num).toInt(), + adminUsername: json['admin_username'] as String, + adminPassword: json['admin_password'] as String, + ); + +Map _$DashboardConfigToJson(DashboardConfig instance) => + { + 'port': instance.port, + 'admin_username': instance.adminUsername, + 'admin_password': instance.adminPassword, + }; + +AppConfig _$AppConfigFromJson(Map json) => AppConfig( + mumble: MumbleConfig.fromJson(json['mumble'] as Map), + bot: BotConfig.fromJson(json['bot'] as Map), + dashboard: DashboardConfig.fromJson( + json['dashboard'] as Map, + ), +); + +Map _$AppConfigToJson(AppConfig instance) => { + 'mumble': instance.mumble.toJson(), + 'bot': instance.bot.toJson(), + 'dashboard': instance.dashboard.toJson(), +}; diff --git a/lib/src/dashboard/api.dart b/lib/src/dashboard/api.dart new file mode 100644 index 0000000..fbe19bf --- /dev/null +++ b/lib/src/dashboard/api.dart @@ -0,0 +1,210 @@ +import 'dart:convert'; + +import 'package:mumbullet/src/audio/downloader.dart'; +import 'package:mumbullet/src/dashboard/auth.dart'; +import 'package:mumbullet/src/logging/logger.dart'; +import 'package:mumbullet/src/queue/music_queue.dart'; +import 'package:mumbullet/src/storage/permission_manager.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf_router/shelf_router.dart'; + +/// Class for handling dashboard API endpoints +class DashboardApi { + final MusicQueue _musicQueue; + final YoutubeDownloader _downloader; + final PermissionManager _permissionManager; + final DashboardAuth _auth; + final LogManager _logManager; + + /// Create a new dashboard API handler + DashboardApi( + this._musicQueue, + this._downloader, + this._permissionManager, + this._auth, + ) : _logManager = LogManager.getInstance(); + + /// Create the router for API endpoints + Router get router { + final app = Router(); + + // Auth endpoints + app.post('/login', _auth.handleLogin); + app.post('/logout', _auth.handleLogout); + + // Queue endpoints + app.get('/queue', _getQueue); + app.delete('/queue', _clearQueue); + + // Cache endpoints + app.get('/cache', _getCacheStats); + app.delete('/cache', _clearCache); + + // User endpoints + app.get('/users', _getUsers); + app.put('/users//permissions', _updateUserPermission); + app.post('/users', _createUser); + + return app; + } + + /// Get the current queue + Response _getQueue(Request request) { + final queue = _musicQueue.getQueue(); + final currentSong = _musicQueue.currentSong; + final state = _musicQueue.state; + + final response = { + 'queue': queue.map((song) => { + 'id': song.id, + 'title': song.title, + 'duration': song.duration, + 'url': song.url, + 'is_current': song == currentSong, + }).toList(), + 'state': state.name, + 'current_song': currentSong == null ? null : { + 'id': currentSong.id, + 'title': currentSong.title, + 'duration': currentSong.duration, + 'url': currentSong.url, + }, + }; + + return Response.ok( + json.encode(response), + headers: {'content-type': 'application/json'}, + ); + } + + /// Clear the queue + Future _clearQueue(Request request) async { + _musicQueue.clear(); + + return Response.ok( + json.encode({'success': true, 'message': 'Queue cleared'}), + headers: {'content-type': 'application/json'}, + ); + } + + /// Get cache statistics + Future _getCacheStats(Request request) async { + final stats = await _downloader.getCacheStats(); + + return Response.ok( + json.encode(stats), + headers: {'content-type': 'application/json'}, + ); + } + + /// Clear the cache + Future _clearCache(Request request) async { + await _downloader.clearCache(); + + return Response.ok( + json.encode({'success': true, 'message': 'Cache cleared'}), + headers: {'content-type': 'application/json'}, + ); + } + + /// Get all users with their permission levels + Future _getUsers(Request request) async { + final permissions = await _permissionManager.getAllUserPermissions(); + + final users = permissions.entries.map((entry) => { + 'username': entry.key, + 'permission_level': entry.value, + 'permission_name': _getPermissionName(entry.value), + }).toList(); + + return Response.ok( + json.encode({'users': users}), + headers: {'content-type': 'application/json'}, + ); + } + + /// Update a user's permission level + Future _updateUserPermission(Request request) async { + final username = request.params['id'] as String; + + final jsonString = await request.readAsString(); + final Map body = json.decode(jsonString); + + final permissionLevel = body['permission_level'] as int?; + + if (permissionLevel == null) { + return Response.badRequest( + body: json.encode({'error': 'Missing permission_level in request body'}), + ); + } + + final success = await _permissionManager.setPermissionLevel(username, permissionLevel); + + if (success) { + return Response.ok( + json.encode({ + 'success': true, + 'message': 'Permission level updated', + 'username': username, + 'permission_level': permissionLevel, + 'permission_name': _getPermissionName(permissionLevel), + }), + headers: {'content-type': 'application/json'}, + ); + } else { + return Response.internalServerError( + body: json.encode({'error': 'Failed to update permission level'}), + ); + } + } + + /// Create a new user + Future _createUser(Request request) async { + final jsonString = await request.readAsString(); + final Map body = json.decode(jsonString); + + final username = body['username'] as String?; + final permissionLevel = body['permission_level'] as int?; + + if (username == null || permissionLevel == null) { + return Response.badRequest( + body: json.encode({'error': 'Missing username or permission_level in request body'}), + ); + } + + final success = await _permissionManager.createUser(username, permissionLevel); + + if (success) { + return Response.ok( + json.encode({ + 'success': true, + 'message': 'User created', + 'username': username, + 'permission_level': permissionLevel, + 'permission_name': _getPermissionName(permissionLevel), + }), + headers: {'content-type': 'application/json'}, + ); + } else { + return Response.internalServerError( + body: json.encode({'error': 'Failed to create user'}), + ); + } + } + + /// Get the name of a permission level + String _getPermissionName(int level) { + switch (level) { + case PermissionLevel.none: + return 'None'; + case PermissionLevel.view: + return 'View Only'; + case PermissionLevel.readWrite: + return 'Read/Write'; + case PermissionLevel.admin: + return 'Admin'; + default: + return 'Unknown'; + } + } +} \ No newline at end of file diff --git a/lib/src/dashboard/auth.dart b/lib/src/dashboard/auth.dart new file mode 100644 index 0000000..77015a5 --- /dev/null +++ b/lib/src/dashboard/auth.dart @@ -0,0 +1,167 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; +import 'package:mumbullet/src/config/config.dart'; +import 'package:mumbullet/src/logging/logger.dart'; +import 'package:shelf/shelf.dart'; + +/// Class for handling dashboard authentication +class DashboardAuth { + final DashboardConfig _config; + final LogManager _logManager; + + // Map of token to expiration time + final Map _sessions = {}; + + // Session duration in minutes + static const int _sessionDurationMinutes = 60; + + /// Create a new dashboard authentication handler + DashboardAuth(this._config) : _logManager = LogManager.getInstance(); + + /// Middleware for authentication + Middleware get middleware { + return (Handler innerHandler) { + return (Request request) async { + // Skip auth for login page and API + final path = request.url.path; + if (path == 'login' || path == 'api/login') { + return innerHandler(request); + } + + // Check for session token + final token = _getSessionToken(request); + if (token != null && _validateSession(token)) { + // Renew session + _renewSession(token); + return innerHandler(request); + } + + // Not authenticated + if (path.startsWith('api/')) { + // API request, return 401 + return Response.unauthorized('Unauthorized'); + } else { + // Web request, redirect to login page + return Response.found('/login'); + } + }; + }; + } + + /// Handle login request + Future handleLogin(Request request) async { + final contentType = request.headers['content-type']; + + if (contentType == 'application/json') { + // API login + final jsonString = await request.readAsString(); + final Map body = json.decode(jsonString); + + final username = body['username'] as String?; + final password = body['password'] as String?; + + if (username == _config.adminUsername && password == _config.adminPassword) { + final token = _createSession(); + return Response.ok( + json.encode({'token': token}), + headers: {'content-type': 'application/json'}, + ); + } else { + return Response(401, body: json.encode({'error': 'Invalid credentials'})); + } + } else { + // Form login + final formData = await request.readAsString(); + final params = Uri.splitQueryString(formData); + + final username = params['username']; + final password = params['password']; + + if (username == _config.adminUsername && password == _config.adminPassword) { + final token = _createSession(); + return Response.found( + '/', + headers: {'set-cookie': 'session=$token; Path=/; HttpOnly'}, + ); + } else { + return Response.found('/login?error=invalid'); + } + } + } + + /// Handle logout request + Response handleLogout(Request request) { + final token = _getSessionToken(request); + if (token != null) { + _sessions.remove(token); + } + + return Response.found( + '/login', + headers: {'set-cookie': 'session=; Path=/; HttpOnly; Max-Age=0'}, + ); + } + + /// Create a new session + String _createSession() { + final random = Random.secure(); + final bytes = List.generate(32, (_) => random.nextInt(256)); + final token = base64Url.encode(bytes); + + _sessions[token] = DateTime.now().add(Duration(minutes: _sessionDurationMinutes)); + + _logManager.info('Created new session'); + return token; + } + + /// Validate a session token + bool _validateSession(String token) { + final expiration = _sessions[token]; + if (expiration == null) { + return false; + } + + if (expiration.isBefore(DateTime.now())) { + _sessions.remove(token); + return false; + } + + return true; + } + + /// Renew a session + void _renewSession(String token) { + _sessions[token] = DateTime.now().add(Duration(minutes: _sessionDurationMinutes)); + } + + /// Get the session token from a request + String? _getSessionToken(Request request) { + // Check authorization header + final authHeader = request.headers['authorization']; + if (authHeader != null && authHeader.startsWith('Bearer ')) { + return authHeader.substring(7); + } + + // Check cookie + final cookies = request.headers['cookie']; + if (cookies != null) { + final cookieParts = cookies.split(';'); + for (final part in cookieParts) { + final cookie = part.trim().split('='); + if (cookie.length == 2 && cookie[0] == 'session') { + return cookie[1]; + } + } + } + + return null; + } + + /// Clean up expired sessions + void cleanupSessions() { + final now = DateTime.now(); + _sessions.removeWhere((_, expiration) => expiration.isBefore(now)); + } +} \ No newline at end of file diff --git a/lib/src/dashboard/server.dart b/lib/src/dashboard/server.dart new file mode 100644 index 0000000..ec85e14 --- /dev/null +++ b/lib/src/dashboard/server.dart @@ -0,0 +1,962 @@ +import 'dart:io'; + +import 'package:mumbullet/src/audio/downloader.dart'; +import 'package:mumbullet/src/config/config.dart'; +import 'package:mumbullet/src/dashboard/api.dart'; +import 'package:mumbullet/src/dashboard/auth.dart'; +import 'package:mumbullet/src/logging/logger.dart'; +import 'package:mumbullet/src/queue/music_queue.dart'; +import 'package:mumbullet/src/storage/permission_manager.dart'; +import 'package:path/path.dart' as path; +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart'; +import 'package:shelf_router/shelf_router.dart'; +import 'package:shelf_static/shelf_static.dart'; + +/// Class for running the dashboard web server +class DashboardServer { + final DashboardConfig _config; + final MusicQueue _musicQueue; + final YoutubeDownloader _downloader; + final PermissionManager _permissionManager; + final LogManager _logManager; + + HttpServer? _server; + final DashboardAuth _auth; + + /// Create a new dashboard server + DashboardServer( + this._config, + this._musicQueue, + this._downloader, + this._permissionManager, + ) : _logManager = LogManager.getInstance(), + _auth = DashboardAuth(_config); + + /// Start the dashboard server + Future start() async { + _logManager.info('Starting dashboard server on port ${_config.port}'); + + try { + // Create static file handler + final staticHandler = createStaticHandler( + _getWebRoot(), + defaultDocument: 'index.html', + ); + + // Create API router + final api = DashboardApi( + _musicQueue, + _downloader, + _permissionManager, + _auth, + ); + + // Create main router + final router = Router(); + + // API routes + router.mount('/api', api.router); + + // Login page + router.get('/login', _handleLoginPage); + + // All other routes go to static files + router.all('/', (Request request) { + return staticHandler(request); + }); + + // Create pipeline with auth middleware + final handler = Pipeline() + .addMiddleware(_auth.middleware) + .addMiddleware(_logRequests()) + .addHandler(router); + + // Start server + _server = await serve(handler, InternetAddress.anyIPv4, _config.port); + + _logManager.info('Dashboard server running at http://localhost:${_config.port}'); + } catch (e, stackTrace) { + _logManager.error('Failed to start dashboard server', e, stackTrace); + rethrow; + } + } + + /// Stop the dashboard server + Future stop() async { + if (_server != null) { + _logManager.info('Stopping dashboard server'); + await _server!.close(); + _server = null; + } + } + + /// Get the path to the web root directory + String _getWebRoot() { + // Use path relative to executable + final executable = Platform.script.toFilePath(); + final executableDir = path.dirname(executable); + + // Try to find web directory in common locations + final possiblePaths = [ + path.join(executableDir, 'web'), + path.join(path.dirname(executableDir), 'web'), + path.join(path.dirname(path.dirname(executableDir)), 'web'), + ]; + + for (final webPath in possiblePaths) { + if (Directory(webPath).existsSync()) { + return webPath; + } + } + + // Fall back to creating a temporary web directory + final tempWebDir = path.join(executableDir, 'web'); + _createDefaultWebFiles(tempWebDir); + return tempWebDir; + } + + /// Create default web files if they don't exist + void _createDefaultWebFiles(String webDir) { + final webDirFile = Directory(webDir); + if (!webDirFile.existsSync()) { + webDirFile.createSync(recursive: true); + } + + // Create index.html + final indexPath = path.join(webDir, 'index.html'); + if (!File(indexPath).existsSync()) { + File(indexPath).writeAsStringSync(_getDefaultIndexHtml()); + } + + // Create login.html + final loginPath = path.join(webDir, 'login.html'); + if (!File(loginPath).existsSync()) { + File(loginPath).writeAsStringSync(_getDefaultLoginHtml()); + } + + // Create style.css + final cssPath = path.join(webDir, 'style.css'); + if (!File(cssPath).existsSync()) { + File(cssPath).writeAsStringSync(_getDefaultCss()); + } + + // Create script.js + final jsPath = path.join(webDir, 'script.js'); + if (!File(jsPath).existsSync()) { + File(jsPath).writeAsStringSync(_getDefaultJs()); + } + } + + /// Handle the login page + Response _handleLoginPage(Request request) { + final queryParams = request.url.queryParameters; + final error = queryParams['error']; + + final errorHtml = error == 'invalid' + ? '
Invalid username or password
' + : ''; + + final html = _getDefaultLoginHtml().replaceAll('', errorHtml); + + return Response.ok( + html, + headers: {'content-type': 'text/html'}, + ); + } + + /// Middleware for logging requests + Middleware _logRequests() { + return (Handler innerHandler) { + return (Request request) async { + final startTime = DateTime.now(); + final response = await innerHandler(request); + final endTime = DateTime.now(); + final duration = endTime.difference(startTime).inMilliseconds; + + _logManager.debug( + '${request.method} ${request.url.path} - ' + '${response.statusCode} (${duration}ms)' + ); + + return response; + }; + }; + } + + /// Get the default index.html content + String _getDefaultIndexHtml() { + return ''' + + + + + MumBullet Dashboard + + + +
+

MumBullet Dashboard

+ +
+ +
+
+

Music Queue

+
+
+

Now Playing

+
+
+

Nothing playing

+
+
+ +
+
+

Queue

+ +
+
+
    +
  • Queue is empty
  • +
+
+
+
+ +
+

User Management

+
+
+

Users

+ +
+
+ + + + + + + + + + + + + +
UsernamePermission LevelActions
No users found
+
+
+ + +
+ +
+

Cache Management

+
+
+

Cache Statistics

+ +
+
+
+
+ Songs + 0 +
+
+ Size + 0 MB +
+
+ Max Size + 0 MB +
+
+ Usage +
+
+
+
+
+
+
+
+
+ + + +'''; + } + + /// Get the default login.html content + String _getDefaultLoginHtml() { + return ''' + + + + + Login - MumBullet Dashboard + + + + + +'''; + } + + /// Get the default CSS content + String _getDefaultCss() { + return '''/* Base styles */ +:root { + --primary-color: #4a6da7; + --primary-dark: #345a96; + --secondary-color: #f5f5f5; + --accent-color: #ff9800; + --danger-color: #f44336; + --text-color: #333333; + --text-light: #ffffff; + --border-color: #dddddd; + --card-bg: #ffffff; + --bg-color: #f0f2f5; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + line-height: 1.6; + color: var(--text-color); + background-color: var(--bg-color); +} + +a { + color: var(--primary-color); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +button { + cursor: pointer; + padding: 8px 16px; + background-color: var(--primary-color); + color: var(--text-light); + border: none; + border-radius: 4px; + font-size: 14px; + transition: background-color 0.2s; +} + +button:hover { + background-color: var(--primary-dark); +} + +.danger-button { + background-color: var(--danger-color); +} + +.danger-button:hover { + background-color: #d32f2f; +} + +/* Layout */ +header { + background-color: var(--primary-color); + color: var(--text-light); + padding: 1rem; + display: flex; + justify-content: space-between; + align-items: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +header h1 { + font-size: 1.5rem; + margin: 0; +} + +nav { + display: flex; + gap: 10px; +} + +main { + max-width: 1200px; + margin: 0 auto; + padding: 1.5rem; +} + +/* Cards */ +.card { + background-color: var(--card-bg); + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + margin-bottom: 1.5rem; + overflow: hidden; +} + +.card-header { + padding: 1rem; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; +} + +.card-header h3 { + margin: 0; + font-size: 1.2rem; +} + +.card-content { + padding: 1rem; +} + +/* Tabs */ +.tab-button { + background: transparent; + color: rgba(255, 255, 255, 0.7); + border: none; + padding: 8px 16px; + border-radius: 4px; +} + +.tab-button.active { + background: rgba(255, 255, 255, 0.2); + color: white; +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* Queue */ +.queue-list { + list-style: none; +} + +.queue-list li { + padding: 10px; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; +} + +.queue-list li:last-child { + border-bottom: none; +} + +.queue-list .now-playing { + background-color: rgba(74, 109, 167, 0.1); + border-left: 4px solid var(--primary-color); +} + +/* Tables */ +.data-table { + width: 100%; + border-collapse: collapse; +} + +.data-table th, .data-table td { + padding: 10px; + text-align: left; + border-bottom: 1px solid var(--border-color); +} + +.data-table th { + background-color: var(--secondary-color); + font-weight: 600; +} + +/* Forms */ +.form-group { + margin-bottom: 15px; +} + +.form-group label { + display: block; + margin-bottom: 5px; + font-weight: 500; +} + +.form-group input, .form-group select { + width: 100%; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 16px; +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 20px; +} + +/* Modal */ +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1000; + justify-content: center; + align-items: center; +} + +.modal.active { + display: flex; +} + +.modal-content { + background-color: var(--card-bg); + border-radius: 8px; + width: 90%; + max-width: 500px; + padding: 20px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); +} + +.modal-content h3 { + margin-bottom: 15px; +} + +/* Stats */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 20px; +} + +.stat-item { + display: flex; + flex-direction: column; +} + +.stat-label { + font-size: 14px; + color: #666; +} + +.stat-value { + font-size: 24px; + font-weight: 600; +} + +/* Progress bar */ +.progress-bar { + width: 100%; + height: 10px; + background-color: #e0e0e0; + border-radius: 5px; + overflow: hidden; + margin-top: 10px; +} + +.progress-value { + height: 100%; + background-color: var(--primary-color); +} + +/* Login page */ +.login-page { + height: 100vh; + display: flex; + justify-content: center; + align-items: center; +} + +.login-container { + width: 90%; + max-width: 400px; +} + +.login-container h1 { + text-align: center; + margin-bottom: 20px; + color: var(--primary-color); +} + +.error { + background-color: rgba(244, 67, 54, 0.1); + color: var(--danger-color); + padding: 10px; + border-radius: 4px; + margin-bottom: 15px; + border-left: 4px solid var(--danger-color); +} + +/* Empty states */ +.empty-message { + text-align: center; + color: #999; + padding: 20px 0; +} + +/* Responsive */ +@media (max-width: 768px) { + header { + flex-direction: column; + align-items: flex-start; + } + + nav { + margin-top: 10px; + width: 100%; + overflow-x: auto; + } + + .stats-grid { + grid-template-columns: 1fr 1fr; + } +}'''; + } + + /// Get the default JavaScript content + String _getDefaultJs() { + // Create and return JavaScript content for the dashboard + // This is returned as a string, and won't be analyzed by Dart + var js = ''' +// DOM Elements +const queueTab = document.getElementById('queueTab'); +const usersTab = document.getElementById('usersTab'); +const cacheTab = document.getElementById('cacheTab'); +const logoutButton = document.getElementById('logoutButton'); + +const queueSection = document.getElementById('queueSection'); +const usersSection = document.getElementById('usersSection'); +const cacheSection = document.getElementById('cacheSection'); + +const nowPlaying = document.getElementById('nowPlaying'); +const queueList = document.getElementById('queueList'); +const clearQueueButton = document.getElementById('clearQueueButton'); + +const usersTable = document.getElementById('usersTable'); +const addUserButton = document.getElementById('addUserButton'); +const addUserModal = document.getElementById('addUserModal'); +const addUserForm = document.getElementById('addUserForm'); +const cancelAddUser = document.getElementById('cancelAddUser'); + +const cacheSongCount = document.getElementById('cacheSongCount'); +const cacheSize = document.getElementById('cacheSize'); +const cacheMaxSize = document.getElementById('cacheMaxSize'); +const cacheUsage = document.getElementById('cacheUsage'); +const clearCacheButton = document.getElementById('clearCacheButton'); + +// Tab switching +function switchTab(tab, section) { + // Remove active class from all tabs and sections + [queueTab, usersTab, cacheTab].forEach(t => t.classList.remove('active')); + [queueSection, usersSection, cacheSection].forEach(s => s.classList.remove('active')); + + // Add active class to selected tab and section + tab.classList.add('active'); + section.classList.add('active'); +} + +queueTab.addEventListener('click', () => switchTab(queueTab, queueSection)); +usersTab.addEventListener('click', () => switchTab(usersTab, usersSection)); +cacheTab.addEventListener('click', () => switchTab(cacheTab, cacheSection)); + +// Logout +logoutButton.addEventListener('click', async () => { + try { + await fetch('/api/logout', { + method: 'POST', + }); + window.location.href = '/login'; + } catch (error) { + console.error('Logout failed:', error); + } +}); + +// Queue management +function formatDuration(seconds) { + const minutes = Math.floor(seconds / 60); + const secs = seconds % 60; + return minutes + ':' + secs.toString().padStart(2, '0'); +} + +async function loadQueue() { + try { + const response = await fetch('/api/queue'); + const responseData = await response.json(); + + // Update now playing + if (responseData.current_song) { + nowPlaying.innerHTML = ` +
+

${responseData.current_song.title}

+

${formatDuration(responseData.current_song.duration)}

+
+
+ ${responseData.state} +
+ `; + } else { + nowPlaying.innerHTML = '

Nothing playing

'; + } + + // Update queue + if (responseData.queue.length > 0) { + queueList.innerHTML = responseData.queue.map((songItem, idx) => { + if (songItem.is_current) { + return ` +
  • +
    + ${songItem.title} + ${formatDuration(songItem.duration)} +
    +
    + Now Playing +
    +
  • + `; + } else { + return ` +
  • +
    + ${idx + 1}. ${songItem.title} + ${formatDuration(songItem.duration)} +
    +
  • + `; + } + }).join(''); + } else { + queueList.innerHTML = '
  • Queue is empty
  • '; + } + } catch (error) { + console.error('Failed to load queue:', error); + } +} + '''; + // Add the rest of the JS code + js += ''' +clearQueueButton.addEventListener('click', async () => { + if (!confirm('Are you sure you want to clear the queue?')) return; + + try { + await fetch('/api/queue', { + method: 'DELETE', + }); + loadQueue(); + } catch (error) { + console.error('Failed to clear queue:', error); + } +}); + +// User management +async function loadUsers() { + try { + const response = await fetch('/api/users'); + const responseData = await response.json(); + + if (responseData.users.length > 0) { + const tbody = usersTable.querySelector('tbody'); + tbody.innerHTML = responseData.users.map(userItem => ` + + ${userItem.username} + + + + + + + + `).join(''); + + // Add event listeners to save buttons + document.querySelectorAll('.save-permission').forEach(button => { + button.addEventListener('click', async () => { + const username = button.dataset.username; + const select = document.querySelector(`.permission-select[data-username="${username}"]`); + const permissionLevel = parseInt(select.value); + + try { + await fetch(`/api/users/${username}/permissions`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ permission_level: permissionLevel }), + }); + alert(`Permission updated for ${username}`); + } catch (error) { + console.error('Failed to update permission:', error); + alert('Failed to update permission'); + } + }); + }); + } else { + usersTable.querySelector('tbody').innerHTML = 'No users found'; + } + } catch (error) { + console.error('Failed to load users:', error); + } +} + +// Add user modal +addUserButton.addEventListener('click', () => { + addUserModal.classList.add('active'); +}); + +cancelAddUser.addEventListener('click', () => { + addUserModal.classList.remove('active'); + addUserForm.reset(); +}); + +addUserForm.addEventListener('submit', async (e) => { + e.preventDefault(); + + const username = document.getElementById('newUsername').value; + const permissionLevel = parseInt(document.getElementById('newPermissionLevel').value); + + try { + await fetch('/api/users', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, permission_level: permissionLevel }), + }); + + addUserModal.classList.remove('active'); + addUserForm.reset(); + loadUsers(); + } catch (error) { + console.error('Failed to add user:', error); + alert('Failed to add user'); + } +}); + +// Cache management +async function loadCacheStats() { + try { + const response = await fetch('/api/cache'); + const stats = await response.json(); + + cacheSongCount.textContent = stats.songCount; + cacheSize.textContent = `${stats.totalSizeMb} MB`; + cacheMaxSize.textContent = `${stats.maxSizeMb} MB`; + + const usagePercent = stats.maxSizeMb > 0 + ? (stats.totalSizeMb / stats.maxSizeMb) * 100 + : 0; + + cacheUsage.style.width = `${Math.min(usagePercent, 100)}%`; + + // Change color based on usage + if (usagePercent > 90) { + cacheUsage.style.backgroundColor = 'var(--danger-color)'; + } else if (usagePercent > 70) { + cacheUsage.style.backgroundColor = 'var(--accent-color)'; + } else { + cacheUsage.style.backgroundColor = 'var(--primary-color)'; + } + } catch (error) { + console.error('Failed to load cache stats:', error); + } +} + +clearCacheButton.addEventListener('click', async () => { + if (!confirm('Are you sure you want to clear the cache? This will delete all downloaded audio files.')) return; + + try { + await fetch('/api/cache', { + method: 'DELETE', + }); + loadCacheStats(); + } catch (error) { + console.error('Failed to clear cache:', error); + } +}); + +// Initial load +document.addEventListener('DOMContentLoaded', () => { + loadQueue(); + loadUsers(); + loadCacheStats(); + + // Refresh data periodically + setInterval(loadQueue, 10000); // Every 10 seconds + setInterval(loadCacheStats, 30000); // Every 30 seconds +}); +'''; + return js; + } +} \ No newline at end of file diff --git a/lib/src/logging/logger.dart b/lib/src/logging/logger.dart new file mode 100644 index 0000000..12f9d68 --- /dev/null +++ b/lib/src/logging/logger.dart @@ -0,0 +1,113 @@ +import 'dart:io'; + +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +class LogManager { + static LogManager? _instance; + final Logger _logger; + late IOSink? _logSink; + + factory LogManager.getInstance() { + _instance ??= LogManager._internal(); + return _instance!; + } + + LogManager._internal() : _logger = Logger('MumbleBullet') { + _logSink = null; + } + + void initialize({ + required Level level, + required bool logToConsole, + String? logFilePath, + int maxLogFiles = 5, + int maxLogSizeBytes = 1024 * 1024, // 1MB + }) { + Logger.root.level = level; + + if (logToConsole) { + Logger.root.onRecord.listen((record) { + stdout.writeln('${record.time}: ${record.level.name}: ${record.message}'); + if (record.error != null) { + stdout.writeln('Error: ${record.error}'); + } + if (record.stackTrace != null) { + stdout.writeln('Stack trace: ${record.stackTrace}'); + } + }); + } + + if (logFilePath != null) { + _setupFileLogging(logFilePath: logFilePath, maxLogFiles: maxLogFiles, maxLogSizeBytes: maxLogSizeBytes); + } + } + + void _setupFileLogging({required String logFilePath, required int maxLogFiles, required int maxLogSizeBytes}) { + final directory = path.dirname(logFilePath); + final dirFile = Directory(directory); + if (!dirFile.existsSync()) { + dirFile.createSync(recursive: true); + } + + // Check if log rotation is needed + final logFile = File(logFilePath); + if (logFile.existsSync() && logFile.lengthSync() > maxLogSizeBytes) { + _rotateLogFiles(logFilePath, maxLogFiles); + } + + _logSink = logFile.openWrite(mode: FileMode.append); + + Logger.root.onRecord.listen((record) { + _logSink?.writeln('${record.time}: ${record.level.name}: ${record.message}'); + if (record.error != null) { + _logSink?.writeln('Error: ${record.error}'); + } + if (record.stackTrace != null) { + _logSink?.writeln('Stack trace: ${record.stackTrace}'); + } + }); + } + + void _rotateLogFiles(String logFilePath, int maxLogFiles) { + // Remove oldest log file if maxLogFiles is reached + for (var i = maxLogFiles; i >= 1; i--) { + final oldFile = File('$logFilePath.$i'); + if (oldFile.existsSync() && i == maxLogFiles) { + oldFile.deleteSync(); + } else if (oldFile.existsSync()) { + oldFile.renameSync('$logFilePath.${i + 1}'); + } + } + + // Rename current log file to .1 + final currentFile = File(logFilePath); + if (currentFile.existsSync()) { + currentFile.renameSync('$logFilePath.1'); + } + } + + void close() { + _logSink?.flush(); + _logSink?.close(); + _logSink = null; + } + + Logger get logger => _logger; + + void debug(String message, [Object? error, StackTrace? stackTrace]) { + _logger.fine(message, error, stackTrace); + } + + void info(String message, [Object? error, StackTrace? stackTrace]) { + _logger.info(message, error, stackTrace); + } + + void warning(String message, [Object? error, StackTrace? stackTrace]) { + _logger.warning(message, error, stackTrace); + } + + void error(String message, [Object? error, StackTrace? stackTrace]) { + _logger.severe(message, error, stackTrace); + } +} diff --git a/lib/src/mumble/audio_streamer.dart b/lib/src/mumble/audio_streamer.dart new file mode 100644 index 0000000..37bc4e5 --- /dev/null +++ b/lib/src/mumble/audio_streamer.dart @@ -0,0 +1,364 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:dumble/dumble.dart'; +import 'package:mumbullet/src/audio/opus_encoder.dart'; +import 'package:mumbullet/src/logging/logger.dart'; +import 'package:mumbullet/src/mumble/connection.dart'; + +/// Class for streaming audio to a Mumble server +class MumbleAudioStreamer { + final MumbleConnection _connection; + final LogManager _logManager; + final OpusAudioEncoder _opusEncoder; + + bool _isStreaming = false; + Timer? _frameTimer; + Completer? _streamingCompleter; + + /// Create a new audio streamer + MumbleAudioStreamer(this._connection) + : _logManager = LogManager.getInstance(), + _opusEncoder = OpusAudioEncoder(); + + /// Returns whether audio is currently streaming + bool get isStreaming => _isStreaming; + + // Frame queue for pre-encoded audio frames + final List _frameQueue = []; + DateTime? _nextFrameTime; + static const Duration _frameDuration = Duration(milliseconds: 20); + + /// Start streaming audio from a file + Future streamAudioFile(String filePath) async { + if (!_connection.isConnected || _connection.client == null) { + _logManager.error('Cannot stream audio: not connected to Mumble server'); + return; + } + + if (_isStreaming) { + _logManager.warning('Already streaming audio, stopping current stream'); + await stopStreaming(); + } + + final file = File(filePath); + if (!file.existsSync()) { + _logManager.error('Audio file not found: $filePath'); + throw FileSystemException('Audio file not found', filePath); + } + + try { + _logManager.info('Starting audio stream from file: $filePath'); + + // Initialize Opus encoder + await _opusEncoder.initialize(); + + _isStreaming = true; + _streamingCompleter = Completer(); + _frameQueue.clear(); + _nextFrameTime = null; + + // Start voice transmission + await _startVoiceTransmission(); + + // Pre-process the entire audio file into frames + await _preprocessAudioFile(file); + + // Start the frame transmission timer + _startFrameTransmission(); + + // Wait for streaming to complete + await _streamingCompleter!.future; + + } catch (e, stackTrace) { + _logManager.error('Failed to start audio stream', e, stackTrace); + await stopStreaming(); + rethrow; + } + } + + /// Pre-process the audio file into encoded frames + Future _preprocessAudioFile(File file) async { + _logManager.info('Pre-processing audio file into frames...'); + + final audioData = await file.readAsBytes(); + + // Parse WAV header to get audio format information + final audioInfo = _parseWavHeader(audioData); + _logManager.info('Audio format: ${audioInfo['sampleRate']}Hz, ${audioInfo['channels']} channels, ${audioInfo['bitDepth']}-bit'); + + // Check if we need to resample/convert the audio + final needsResampling = audioInfo['sampleRate'] != 48000 || audioInfo['channels'] != 1; + + File processedFile = file; + bool isTemporaryFile = false; + + if (needsResampling) { + _logManager.info('Audio needs resampling/conversion to 48kHz mono'); + processedFile = await _resampleAudioFile(file, audioInfo); + isTemporaryFile = true; + } else { + _logManager.info('Audio format is already 48kHz mono, processing directly'); + } + + try { + // Read the processed audio file + final processedAudioData = await processedFile.readAsBytes(); + + // Skip WAV header (44 bytes) to get to PCM data + final pcmData = processedAudioData.sublist(44); + + final frameByteSize = _opusEncoder.frameSizeBytes; + _logManager.info('PCM data size: ${pcmData.length} bytes, frame size: $frameByteSize bytes'); + + // Process audio data in chunks + for (int offset = 0; offset < pcmData.length; offset += frameByteSize) { + if (!_isStreaming) break; // Stop if streaming was cancelled + + final remainingBytes = pcmData.length - offset; + final chunkSize = remainingBytes >= frameByteSize ? frameByteSize : remainingBytes; + + if (chunkSize < frameByteSize) { + // Pad the last frame with silence if it's too short + final paddedFrame = Uint8List(frameByteSize); + paddedFrame.setRange(0, chunkSize, pcmData, offset); + // Rest of the frame is already zero (silence) + + final encodedFrame = _opusEncoder.encode(paddedFrame); + _frameQueue.add(encodedFrame); + } else { + final frameData = Uint8List.fromList(pcmData.sublist(offset, offset + frameByteSize)); + final encodedFrame = _opusEncoder.encode(frameData); + _frameQueue.add(encodedFrame); + } + } + + _logManager.info('Pre-processed ${_frameQueue.length} audio frames'); + + } finally { + // Clean up temporary file if we created one + if (isTemporaryFile && processedFile.existsSync()) { + try { + await processedFile.delete(); + _logManager.debug('Cleaned up temporary resampled file: ${processedFile.path}'); + } catch (e) { + _logManager.warning('Failed to clean up temporary file: ${processedFile.path}, error: $e'); + } + } + } + } + + /// Resample audio file to 48kHz mono using FFmpeg + Future _resampleAudioFile(File inputFile, Map audioInfo) async { + _logManager.info('Resampling ${audioInfo['sampleRate']}Hz ${audioInfo['channels']}-channel audio to 48kHz mono'); + + // Create temporary output file + final tempDir = Directory.systemTemp; + final tempFile = File('${tempDir.path}/mumbullet_resampled_${DateTime.now().millisecondsSinceEpoch}.wav'); + + try { + // Build FFmpeg command for resampling + final args = [ + '-i', inputFile.path, // Input file + '-ar', '48000', // Sample rate: 48kHz + '-ac', '1', // Channels: mono + '-sample_fmt', 's16', // Sample format: 16-bit signed + '-f', 'wav', // Output format: WAV + '-y', // Overwrite output file + tempFile.path // Output file + ]; + + _logManager.debug('Running FFmpeg: ffmpeg ${args.join(' ')}'); + + // Run FFmpeg + final result = await Process.run('ffmpeg', args); + + if (result.exitCode != 0) { + throw Exception('FFmpeg resampling failed (exit code ${result.exitCode}): ${result.stderr}'); + } + + // Verify the output file was created + if (!tempFile.existsSync()) { + throw Exception('FFmpeg completed but output file was not created'); + } + + final outputSize = await tempFile.length(); + _logManager.info('Successfully resampled audio: ${inputFile.path} → ${tempFile.path} ($outputSize bytes)'); + + return tempFile; + + } catch (e, stackTrace) { + // Clean up temp file on error + if (tempFile.existsSync()) { + try { + await tempFile.delete(); + } catch (_) { + // Ignore cleanup errors + } + } + + _logManager.error('Failed to resample audio file', e, stackTrace); + rethrow; + } + } + + /// Parse WAV file header to extract audio format information + Map _parseWavHeader(Uint8List data) { + if (data.length < 44) { + throw Exception('Invalid WAV file: too small'); + } + + // Check RIFF header + final riffHeader = String.fromCharCodes(data.sublist(0, 4)); + if (riffHeader != 'RIFF') { + throw Exception('Invalid WAV file: missing RIFF header'); + } + + // Check WAVE format + final waveHeader = String.fromCharCodes(data.sublist(8, 12)); + if (waveHeader != 'WAVE') { + throw Exception('Invalid WAV file: not a WAVE file'); + } + + // Extract audio format information (little-endian) + final channels = data[22] | (data[23] << 8); + final sampleRate = data[24] | (data[25] << 8) | (data[26] << 16) | (data[27] << 24); + final bitDepth = data[34] | (data[35] << 8); + + return { + 'channels': channels, + 'sampleRate': sampleRate, + 'bitDepth': bitDepth, + }; + } + + + /// Start the precise frame transmission timer + void _startFrameTransmission() { + if (_frameQueue.isEmpty) { + _logManager.warning('No frames to transmit'); + stopStreaming(); + return; + } + + _nextFrameTime = DateTime.now(); + _logManager.info('Starting frame transmission with ${_frameQueue.length} frames'); + + // Use a high-frequency timer to check for frame transmission + _frameTimer = Timer.periodic(Duration(milliseconds: 1), (timer) { + _checkAndSendFrame(); + }); + } + + /// Check if it's time to send the next frame and send it + void _checkAndSendFrame() { + if (!_isStreaming || _frameQueue.isEmpty || _nextFrameTime == null) { + if (_frameQueue.isEmpty && _isStreaming) { + _logManager.info('All frames transmitted, stopping stream'); + stopStreaming(); + } + return; + } + + final now = DateTime.now(); + if (now.isAfter(_nextFrameTime!) || now.isAtSameMomentAs(_nextFrameTime!)) { + // Time to send the next frame + final frame = _frameQueue.removeAt(0); + _sendEncodedFrame(frame); + + // Schedule next frame + _nextFrameTime = _nextFrameTime!.add(_frameDuration); + + // Log timing information occasionally + if (_frameQueue.length % 50 == 0) { + final timingError = now.difference(_nextFrameTime!.subtract(_frameDuration)).inMilliseconds; + _logManager.debug('Frame timing: ${_frameQueue.length} frames remaining, timing error: ${timingError}ms'); + } + } + } + + AudioFrameSink? _audioSink; + + /// Start voice transmission on the Mumble client + Future _startVoiceTransmission() async { + try { + final client = _connection.client!; + + // Create an audio sink for sending audio frames + _audioSink = client.audio.sendAudio(codec: AudioCodec.opus); + + _logManager.info('Started voice transmission'); + } catch (e, stackTrace) { + _logManager.error('Failed to start voice transmission', e, stackTrace); + rethrow; + } + } + + /// Stop voice transmission on the Mumble client + Future _stopVoiceTransmission() async { + try { + if (_audioSink != null) { + await _audioSink!.close(); + _audioSink = null; + _logManager.info('Stopped voice transmission'); + } + } catch (e, stackTrace) { + _logManager.error('Failed to stop voice transmission', e, stackTrace); + } + } + + /// Send a pre-encoded audio frame to the Mumble server + void _sendEncodedFrame(Uint8List encodedData) { + if (!_isStreaming || _connection.client == null || _audioSink == null) { + return; + } + + try { + // Create an audio frame and send it + final audioFrame = AudioFrame.outgoing(frame: encodedData); + _audioSink!.add(audioFrame); + + _logManager.debug('Sent Opus frame: ${encodedData.length} bytes'); + } catch (e, stackTrace) { + _logManager.error('Failed to send audio frame', e, stackTrace); + stopStreaming(); + } + } + + /// Stop the current audio stream + Future stopStreaming() async { + if (!_isStreaming) { + return; + } + + _logManager.info('Stopping audio stream'); + + _isStreaming = false; + + // Stop voice transmission + await _stopVoiceTransmission(); + + // Cancel the frame timer + _frameTimer?.cancel(); + _frameTimer = null; + + // Clear the frame queue + _frameQueue.clear(); + _nextFrameTime = null; + + // Complete the streaming completer if it exists + if (_streamingCompleter != null && !_streamingCompleter!.isCompleted) { + _streamingCompleter!.complete(); + _streamingCompleter = null; + } + + _logManager.info('Audio stream stopped'); + } + + /// Dispose the audio streamer + void dispose() { + stopStreaming(); + _opusEncoder.dispose(); + } +} diff --git a/lib/src/mumble/connection.dart b/lib/src/mumble/connection.dart new file mode 100644 index 0000000..aede93c --- /dev/null +++ b/lib/src/mumble/connection.dart @@ -0,0 +1,382 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dumble/dumble.dart'; +import 'package:logging/logging.dart'; +import 'package:mumbullet/src/config/config.dart'; +import 'package:mumbullet/src/logging/logger.dart'; +import 'package:mumbullet/src/mumble/models.dart' as models; + +/// Client listener for Mumble events +class MumbleEventHandler with MumbleClientListener { + final int _selfSession; + final LogManager _logManager; + final void Function(models.MumbleUser) _onUserJoined; + final void Function(models.MumbleUser) _onUserLeft; + final void Function(String, models.MumbleUser) _onTextMessage; + + MumbleEventHandler( + this._selfSession, + this._logManager, + this._onUserJoined, + this._onUserLeft, + this._onTextMessage, + ); + + @override + void onUserAdded(User user) { + if (user.session != _selfSession && user.name != null) { + final mumbleUser = models.MumbleUser( + session: user.session, + name: user.name!, + ); + _onUserJoined(mumbleUser); + _logManager.info('User joined: ${user.name}'); + } + } + + @override + void onUserRemoved(User user, User? actor, String? reason, bool? ban) { + if (user.session != _selfSession && user.name != null) { + final mumbleUser = models.MumbleUser( + session: user.session, + name: user.name!, + ); + _onUserLeft(mumbleUser); + + String banInfo = ban == true ? ' (banned)' : ''; + String actorInfo = actor != null && actor.name != null ? ' by ${actor.name}' : ''; + String reasonInfo = reason != null ? ' Reason: $reason' : ''; + + _logManager.info('User left: ${user.name}$banInfo$actorInfo$reasonInfo'); + } + } + + @override + void onTextMessage(IncomingTextMessage message) { + if (message.actor != null && message.actor!.name != null) { + final sender = models.MumbleUser( + session: message.actor!.session, + name: message.actor!.name!, + ); + + _onTextMessage(message.message, sender); + + String channelInfo = ''; + if (message.channels.isNotEmpty && message.channels.first.name != null) { + channelInfo = ' (to channel: ${message.channels.first.name})'; + } + + _logManager.info('Text message from ${message.actor!.name}$channelInfo: ${message.message}'); + } + } + + // Required overrides for MumbleClientListener + @override + void onChannelAdded(Channel channel) {} + + @override + void onBanListReceived(List bans) {} + + @override + void onError(Object error, [StackTrace? stackTrace]) { + _logManager.error('Mumble client error', error, stackTrace); + } + + @override + void onDone() { + _logManager.info('Mumble client closed connection'); + } + + @override + void onPermissionDenied(PermissionDeniedException e) { + _logManager.warning('Permission denied: ${e.reason}'); + } + + @override + void onCryptStateChanged() {} + + @override + void onDropAllChannelPermissions() {} + + @override + void onQueryUsersResult(Map idToName) {} + + @override + void onUserListReceived(List users) {} +} + +/// Class for managing the connection to a Mumble server +class MumbleConnection { + final MumbleConfig _config; + final LogManager _logManager; + + MumbleClient? _client; + Channel? _currentChannel; + bool _isConnected = false; + final _reconnectDelays = [1, 2, 5, 10, 30, 60]; // Seconds + int _reconnectAttempt = 0; + Timer? _reconnectTimer; + + /// Stream controller for connection state changes + final _connectionStateController = StreamController.broadcast(); + + /// Stream controller for user join/leave events + final _userJoinedController = StreamController.broadcast(); + final _userLeftController = StreamController.broadcast(); + + /// Stream controller for text messages + final _textMessageController = StreamController>.broadcast(); + + /// Create a new Mumble connection + MumbleConnection(this._config) : _logManager = LogManager.getInstance(); + + /// Returns the current connection state + bool get isConnected => _isConnected; + + /// Stream of connection state changes + Stream get connectionState => _connectionStateController.stream; + + /// Stream of user joined events + Stream get userJoined => _userJoinedController.stream; + + /// Stream of user left events + Stream get userLeft => _userLeftController.stream; + + /// Stream of text messages - returns {message: String, sender: MumbleUser} + Stream> get textMessages => _textMessageController.stream; + + /// Get the current Mumble client + MumbleClient? get client => _client; + + /// Get the current channel + models.MumbleChannel? get currentChannel { + if (_currentChannel == null) return null; + + return models.MumbleChannel( + id: _currentChannel!.channelId, + name: _currentChannel!.name ?? 'Unknown', + parentId: _currentChannel!.parent?.channelId, + ); + } + + /// Connect to the Mumble server + Future connect() async { + if (_isConnected) { + _logManager.info('Already connected to Mumble server'); + return; + } + + try { + _logManager.info('Connecting to Mumble server ${_config.server}:${_config.port}'); + + // Set up connection options + final options = ConnectionOptions( + host: _config.server, + port: _config.port, + name: _config.username, + password: _config.password, + ); + + // Connect to the server + final client = await MumbleClient.connect( + options: options, + onBadCertificate: (_) => true, // Accept all certificates for now + ); + + _client = client; + _isConnected = true; + _reconnectAttempt = 0; + _connectionStateController.add(true); + + _logManager.info('Connected to Mumble server as ${client.self.name ?? _config.username}'); + + // Register event handler + client.add(MumbleEventHandler( + client.self.session, + _logManager, + (user) => _userJoinedController.add(user), + (user) => _userLeftController.add(user), + (message, sender) => _textMessageController.add({ + 'message': message, + 'sender': sender, + }), + )); + + // Join the configured channel if specified + if (_config.channel.isNotEmpty) { + await joinChannel(_config.channel); + } + + } catch (e, stackTrace) { + _logManager.error('Failed to connect to Mumble server', e, stackTrace); + _isConnected = false; + _connectionStateController.add(false); + _scheduleReconnect(); + rethrow; + } + } + + /// Join a channel by name + Future joinChannel(String channelName) async { + if (!_isConnected || _client == null) { + _logManager.error('Cannot join channel: not connected to Mumble server'); + return; + } + + try { + // Get all channels + final channels = _client!.getChannels(); + + // Find the root channel + Channel? rootChannel = channels[rootChannelId]; + + if (rootChannel == null) { + throw Exception('Root channel not found'); + } + + // If channelName is empty, join the root channel + if (channelName.isEmpty) { + _client!.self.moveToChannel(channel: rootChannel); + _currentChannel = rootChannel; + _logManager.info('Joined root channel'); + return; + } + + // Find the target channel by name + Channel? targetChannel; + for (final channel in channels.values) { + if (channel.name == channelName) { + targetChannel = channel; + break; + } + } + + if (targetChannel != null) { + _client!.self.moveToChannel(channel: targetChannel); + _currentChannel = targetChannel; + _logManager.info('Joined channel: $channelName'); + } else { + _logManager.warning('Channel not found: $channelName. Joining root channel.'); + _client!.self.moveToChannel(channel: rootChannel); + _currentChannel = rootChannel; + _logManager.info('Joined root channel as fallback'); + } + } catch (e, stackTrace) { + _logManager.error('Failed to join channel: $channelName', e, stackTrace); + rethrow; + } + } + + /// Send a text message to the current channel + Future sendChannelMessage(String message) async { + if (!_isConnected || _client == null || _currentChannel == null) { + _logManager.error('Cannot send message: not connected or no current channel'); + return; + } + + try { + // Send message to the current channel + _currentChannel!.sendMessageToChannel(message: message); + _logManager.debug('Sent channel message: $message'); + } catch (e, stackTrace) { + _logManager.error('Failed to send channel message', e, stackTrace); + rethrow; + } + } + + /// Send a private message to a user + Future sendPrivateMessage(String message, models.MumbleUser recipient) async { + if (!_isConnected || _client == null) { + _logManager.error('Cannot send message: not connected'); + return; + } + + try { + // Find the user + final users = _client!.getUsers(); + User? user; + + for (final u in users.values) { + if (u.session == recipient.session) { + user = u; + break; + } + } + + if (user == null) { + throw Exception('User not found: ${recipient.name}'); + } + + // Send message to the user + user.sendMessageToUser(message: message); + _logManager.debug('Sent private message to ${recipient.name}: $message'); + } catch (e, stackTrace) { + _logManager.error('Failed to send private message', e, stackTrace); + rethrow; + } + } + + /// Disconnect from the Mumble server + Future disconnect() async { + if (!_isConnected || _client == null) { + _logManager.info('Not connected to Mumble server'); + return; + } + + try { + _logManager.info('Disconnecting from Mumble server'); + + // Cancel any pending reconnection + _reconnectTimer?.cancel(); + _reconnectTimer = null; + + await _client!.close(); + _client = null; + _isConnected = false; + _currentChannel = null; + _connectionStateController.add(false); + + _logManager.info('Disconnected from Mumble server'); + } catch (e, stackTrace) { + _logManager.error('Error disconnecting from Mumble server', e, stackTrace); + rethrow; + } + } + + /// Schedule a reconnection attempt + void _scheduleReconnect() { + if (_reconnectTimer != null) { + return; // Already scheduled + } + + final delay = _reconnectAttempt < _reconnectDelays.length + ? _reconnectDelays[_reconnectAttempt] + : _reconnectDelays.last; + + _logManager.info('Scheduling reconnection attempt in $delay seconds'); + + _reconnectTimer = Timer(Duration(seconds: delay), () async { + _reconnectTimer = null; + _reconnectAttempt++; + + try { + await connect(); + } catch (e) { + _logManager.error('Reconnection attempt failed', e); + // The connect method will schedule another reconnect + } + }); + } + + /// Dispose the connection + void dispose() { + _reconnectTimer?.cancel(); + _reconnectTimer = null; + _connectionStateController.close(); + _userJoinedController.close(); + _userLeftController.close(); + _textMessageController.close(); + disconnect(); + } +} \ No newline at end of file diff --git a/lib/src/mumble/message_handler.dart b/lib/src/mumble/message_handler.dart new file mode 100644 index 0000000..992468c --- /dev/null +++ b/lib/src/mumble/message_handler.dart @@ -0,0 +1,157 @@ +import 'dart:async'; + +import 'package:dumble/dumble.dart' as dumble; +import 'package:mumbullet/src/config/config.dart'; +import 'package:mumbullet/src/logging/logger.dart'; +import 'package:mumbullet/src/mumble/connection.dart'; +import 'package:mumbullet/src/mumble/models.dart'; + +/// Type definition for message handler callback +typedef MessageCallback = void Function(String message, MumbleUser sender, bool isPrivate); + +/// Class for handling and sending Mumble messages +class MumbleMessageHandler { + final MumbleConnection _connection; + final BotConfig _config; + final LogManager _logManager; + + /// Stream controller for incoming commands + final _commandController = StreamController>.broadcast(); + + final List _subscriptions = []; + + /// Create a new message handler + MumbleMessageHandler(this._connection, this._config) + : _logManager = LogManager.getInstance() { + _setupHandlers(); + } + + /// Stream of incoming commands + Stream> get commandStream => _commandController.stream; + + /// Set up message handlers + void _setupHandlers() { + // Wait for connection + _connection.connectionState.listen((isConnected) { + if (isConnected) { + _logManager.debug('Connected, setting up message handlers'); + _setupMessageHandlers(); + } else { + _logManager.debug('Disconnected, cleaning up message handlers'); + _cleanupMessageHandlers(); + } + }); + + // Setup immediately if already connected + if (_connection.isConnected) { + _setupMessageHandlers(); + } + } + + /// Set up message handlers for the current connection + void _setupMessageHandlers() { + final client = _connection.client; + if (client == null) { + _logManager.warning('Cannot set up message handlers: client is null'); + return; + } + + // Listen to the connection's text message stream + final textMessageSub = _connection.textMessages.listen((data) { + final message = data['message'] as String; + final sender = data['sender'] as MumbleUser; + + // For now, assume all messages are channel messages (not private) + _processMessage(message, sender, false); + }); + + _subscriptions.add(textMessageSub); + _logManager.info('Message handlers set up'); + } + + /// Clean up message handlers + void _cleanupMessageHandlers() { + for (final subscription in _subscriptions) { + subscription.cancel(); + } + _subscriptions.clear(); + } + + /// Process an incoming message + void _processMessage(String message, MumbleUser sender, bool isPrivate) { + // Check if message starts with command prefix + if (message.startsWith(_config.commandPrefix)) { + final commandText = message.substring(_config.commandPrefix.length).trim(); + + if (commandText.isNotEmpty) { + _logManager.info('Received command from ${sender.name}: $commandText'); + + // Parse the command and arguments + final commandParts = commandText.split(' '); + final command = commandParts[0].toLowerCase(); + final args = commandParts.length > 1 + ? commandParts.sublist(1).join(' ') + : ''; + + // Emit the command + _commandController.add({ + 'command': command, + 'args': args, + 'sender': sender, + 'isPrivate': isPrivate, + }); + } + } + } + + /// Send a message to a channel + Future sendChannelMessage(String message, [MumbleChannel? channel]) async { + if (!_connection.isConnected || _connection.client == null) { + _logManager.error('Cannot send message: not connected'); + return; + } + + try { + // TODO: Implement proper channel message sending with dumble + // For now, we'll use the connection's sendChannelMessage method + await _connection.sendChannelMessage(message); + _logManager.debug('Sent channel message: $message'); + } catch (e, stackTrace) { + _logManager.error('Failed to send channel message', e, stackTrace); + rethrow; + } + } + + /// Send a private message to a user + Future sendPrivateMessage(String message, MumbleUser user) async { + if (!_connection.isConnected || _connection.client == null) { + _logManager.error('Cannot send message: not connected'); + return; + } + + try { + // TODO: Implement proper private message sending with dumble + // For now, we'll use the connection's sendPrivateMessage method + await _connection.sendPrivateMessage(message, user); + _logManager.debug('Sent private message to ${user.name}: $message'); + } catch (e, stackTrace) { + _logManager.error('Failed to send private message', e, stackTrace); + rethrow; + } + } + + /// Reply to a message in the same context it was received + Future replyToMessage(String message, MumbleUser recipient, bool wasPrivate) async { + if (wasPrivate) { + await sendPrivateMessage(message, recipient); + } else { + await sendChannelMessage(message); + } + } + + /// Dispose the message handler + void dispose() { + _cleanupMessageHandlers(); + _commandController.close(); + } +} diff --git a/lib/src/mumble/models.dart b/lib/src/mumble/models.dart new file mode 100644 index 0000000..3c6068e --- /dev/null +++ b/lib/src/mumble/models.dart @@ -0,0 +1,41 @@ +/// Simple model classes for Mumble entities + +/// Represents a user in a Mumble server +class MumbleUser { + /// The user's session ID + final int session; + + /// The user's name + final String name; + + /// Create a new MumbleUser + MumbleUser({ + required this.session, + required this.name, + }); + + @override + String toString() => 'MumbleUser(session: $session, name: $name)'; +} + +/// Represents a channel in a Mumble server +class MumbleChannel { + /// The channel's ID + final int id; + + /// The channel's name + final String name; + + /// The channel's parent ID (null for root channel) + final int? parentId; + + /// Create a new MumbleChannel + MumbleChannel({ + required this.id, + required this.name, + this.parentId, + }); + + @override + String toString() => 'MumbleChannel(id: $id, name: $name, parentId: $parentId)'; +} \ No newline at end of file diff --git a/lib/src/queue/music_queue.dart b/lib/src/queue/music_queue.dart new file mode 100644 index 0000000..ca99b61 --- /dev/null +++ b/lib/src/queue/music_queue.dart @@ -0,0 +1,246 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:mumbullet/src/audio/converter.dart'; +import 'package:mumbullet/src/audio/downloader.dart'; +import 'package:mumbullet/src/config/config.dart'; +import 'package:mumbullet/src/logging/logger.dart'; +import 'package:mumbullet/src/mumble/audio_streamer.dart'; + +/// States for the music queue +enum QueueState { + idle, + playing, + paused, +} + +/// Events emitted by the music queue +enum QueueEvent { + songAdded, + songStarted, + songFinished, + queueCleared, + queueEmpty, + playbackPaused, + playbackResumed, +} + +/// Class for managing the music queue +class MusicQueue { + final BotConfig _config; + final MumbleAudioStreamer _audioStreamer; + final AudioConverter _audioConverter; + final LogManager _logManager; + + final Queue _queue = Queue(); + Song? _currentSong; + QueueState _state = QueueState.idle; + + final _eventController = StreamController>.broadcast(); + Completer? _playbackCompleter; + + /// Create a new music queue + MusicQueue( + this._config, + this._audioStreamer, + this._audioConverter, + ) : _logManager = LogManager.getInstance(); + + /// Stream of queue events + Stream> get events => _eventController.stream; + + /// Get the current queue state + QueueState get state => _state; + + /// Get the current song + Song? get currentSong => _currentSong; + + /// Get a copy of the queue + List getQueue() { + return List.from(_queue); + } + + /// Add a song to the queue + int enqueue(Song song, [bool playImmediately = false]) { + if (playImmediately) { + _logManager.info('Adding song to play immediately: ${song.title}'); + + // Clear the queue and add the song + _queue.clear(); + _queue.add(song); + + // Start playback if not already playing + if (_state != QueueState.playing || _currentSong != song) { + _playSong(song); + } + + _emitEvent(QueueEvent.queueCleared, {'clearReason': 'playImmediately'}); + return 0; + } else { + _logManager.info('Adding song to queue: ${song.title}'); + + // Check if queue is at max size + if (_queue.length >= _config.maxQueueSize) { + _logManager.warning('Queue is full, cannot add song: ${song.title}'); + return -1; + } + + // Add to queue + _queue.add(song); + + // Start playback if queue was empty + if (_state == QueueState.idle) { + _playNext(); + } + + final position = _queue.length - 1; + _emitEvent(QueueEvent.songAdded, {'song': song, 'position': position}); + return position; + } + } + + /// Skip the current song + bool skip() { + if (_state != QueueState.playing && _state != QueueState.paused) { + _logManager.warning('Cannot skip: not playing'); + return false; + } + + _logManager.info('Skipping current song: ${_currentSong?.title}'); + + // Stop current playback + _audioStreamer.stopStreaming(); + + // Complete the playback completer + _playbackCompleter?.complete(); + _playbackCompleter = null; + + // Play next song + return _playNext(); + } + + /// Clear the queue + void clear() { + _logManager.info('Clearing queue'); + + // Stop current playback + _audioStreamer.stopStreaming(); + + // Complete the playback completer + _playbackCompleter?.complete(); + _playbackCompleter = null; + + // Clear the queue + _queue.clear(); + _currentSong = null; + _state = QueueState.idle; + + _emitEvent(QueueEvent.queueCleared, {'clearReason': 'userCommand'}); + _emitEvent(QueueEvent.queueEmpty, {}); + } + + /// Pause playback + bool pause() { + if (_state != QueueState.playing) { + _logManager.warning('Cannot pause: not playing'); + return false; + } + + _logManager.info('Pausing playback'); + + // TODO: Implement pause in audio streamer + // For now, we'll just stop and mark as paused + _audioStreamer.stopStreaming(); + _state = QueueState.paused; + + _emitEvent(QueueEvent.playbackPaused, {'song': _currentSong}); + return true; + } + + /// Resume playback + bool resume() { + if (_state != QueueState.paused) { + _logManager.warning('Cannot resume: not paused'); + return false; + } + + _logManager.info('Resuming playback'); + + // TODO: Implement resume in audio streamer + // For now, we'll just restart the song + if (_currentSong != null) { + _playSong(_currentSong!); + } else { + _playNext(); + } + + _emitEvent(QueueEvent.playbackResumed, {'song': _currentSong}); + return true; + } + + /// Play the next song in the queue + bool _playNext() { + if (_queue.isEmpty) { + _logManager.info('Queue is empty, nothing to play next'); + _currentSong = null; + _state = QueueState.idle; + _emitEvent(QueueEvent.queueEmpty, {}); + return false; + } + + // Get the next song + final nextSong = _queue.removeFirst(); + _playSong(nextSong); + return true; + } + + /// Play a specific song + Future _playSong(Song song) async { + _logManager.info('Playing song: ${song.title}'); + + _currentSong = song; + _state = QueueState.playing; + + _emitEvent(QueueEvent.songStarted, {'song': song}); + + try { + // Create a completer for tracking playback completion + _playbackCompleter = Completer(); + + // Start streaming the audio + await _audioStreamer.streamAudioFile(song.filePath); + + // Wait for playback to complete or be skipped + await _playbackCompleter?.future; + + _logManager.info('Song finished: ${song.title}'); + _emitEvent(QueueEvent.songFinished, {'song': song}); + + // Play the next song + _playNext(); + } catch (e, stackTrace) { + _logManager.error('Error playing song: ${song.title}', e, stackTrace); + + // Skip to next song on error + _playNext(); + } + } + + /// Emit a queue event + void _emitEvent(QueueEvent event, Map data) { + _eventController.add({ + 'event': event.name, + 'state': _state.name, + 'data': data, + }); + } + + /// Dispose the music queue + void dispose() { + _audioStreamer.stopStreaming(); + _queue.clear(); + _currentSong = null; + _state = QueueState.idle; + _eventController.close(); + } +} \ No newline at end of file diff --git a/lib/src/storage/database.dart b/lib/src/storage/database.dart new file mode 100644 index 0000000..7fd5657 --- /dev/null +++ b/lib/src/storage/database.dart @@ -0,0 +1,430 @@ +import 'dart:io'; + +import 'package:mumbullet/src/audio/downloader.dart'; +import 'package:mumbullet/src/logging/logger.dart'; +import 'package:path/path.dart' as path; +import 'package:sqlite3/sqlite3.dart'; + +/// Model for a user +class User { + final int id; + final String username; + final DateTime createdAt; + + User({ + required this.id, + required this.username, + required this.createdAt, + }); +} + +/// Repository for user data +class UserRepository { + final Database _db; + final LogManager _logManager; + + /// Create a new user repository + UserRepository(this._db) : _logManager = LogManager.getInstance(); + + /// Create the users table if it doesn't exist + void createTable() { + _db.execute(''' + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + '''); + } + + /// Get a user by ID + Future getUserById(int id) async { + try { + final result = _db.select( + 'SELECT * FROM users WHERE id = ?', + [id], + ); + + if (result.isEmpty) { + return null; + } + + final row = result.first; + + return User( + id: row['id'] as int, + username: row['username'] as String, + createdAt: DateTime.parse(row['created_at'] as String), + ); + } catch (e, stackTrace) { + _logManager.error('Failed to get user by ID: $id', e, stackTrace); + return null; + } + } + + /// Get a user by username + Future getUserByName(String username) async { + try { + final result = _db.select( + 'SELECT * FROM users WHERE username = ?', + [username], + ); + + if (result.isEmpty) { + return null; + } + + final row = result.first; + + return User( + id: row['id'] as int, + username: row['username'] as String, + createdAt: DateTime.parse(row['created_at'] as String), + ); + } catch (e, stackTrace) { + _logManager.error('Failed to get user by name: $username', e, stackTrace); + return null; + } + } + + /// Create a new user + Future createUser(String username) async { + try { + _db.execute( + 'INSERT INTO users (username) VALUES (?)', + [username], + ); + + final id = _db.lastInsertRowId; + + return User( + id: id, + username: username, + createdAt: DateTime.now(), + ); + } catch (e, stackTrace) { + _logManager.error('Failed to create user: $username', e, stackTrace); + rethrow; + } + } + + /// Get all users + Future> getAllUsers() async { + try { + final result = _db.select('SELECT * FROM users ORDER BY username'); + + return result.map((row) => User( + id: row['id'] as int, + username: row['username'] as String, + createdAt: DateTime.parse(row['created_at'] as String), + )).toList(); + } catch (e, stackTrace) { + _logManager.error('Failed to get all users', e, stackTrace); + return []; + } + } +} + +/// Repository for permission data +class PermissionRepository { + final Database _db; + final LogManager _logManager; + + /// Create a new permission repository + PermissionRepository(this._db) : _logManager = LogManager.getInstance(); + + /// Create the permissions tables if they don't exist + void createTables() { + _db.execute(''' + CREATE TABLE IF NOT EXISTS permissions ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + description TEXT + ) + '''); + + _db.execute(''' + CREATE TABLE IF NOT EXISTS user_permissions ( + user_id INTEGER, + permission_level INTEGER DEFAULT 0, + granted_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id), + FOREIGN KEY (permission_level) REFERENCES permissions (id) + ) + '''); + + // Insert default permissions if they don't exist + final permissions = _db.select('SELECT COUNT(*) as count FROM permissions'); + if (permissions.first['count'] == 0) { + _db.execute(''' + INSERT INTO permissions (id, name, description) VALUES + (0, 'none', 'No access to any commands'), + (1, 'view', 'Access to view-only commands'), + (2, 'read_write', 'Access to most commands'), + (3, 'admin', 'Access to all commands') + '''); + } + } + + /// Get a user's permission level + Future getUserPermission(int userId) async { + try { + final result = _db.select( + 'SELECT permission_level FROM user_permissions WHERE user_id = ?', + [userId], + ); + + if (result.isEmpty) { + return null; + } + + return result.first['permission_level'] as int; + } catch (e, stackTrace) { + _logManager.error('Failed to get permission for user ID: $userId', e, stackTrace); + return null; + } + } + + /// Set a user's permission level + Future setUserPermission(int userId, int level) async { + try { + // Check if user already has a permission level + final existing = await getUserPermission(userId); + + if (existing == null) { + // Insert new permission + _db.execute( + 'INSERT INTO user_permissions (user_id, permission_level) VALUES (?, ?)', + [userId, level], + ); + } else { + // Update existing permission + _db.execute( + 'UPDATE user_permissions SET permission_level = ?, granted_at = CURRENT_TIMESTAMP WHERE user_id = ?', + [level, userId], + ); + } + } catch (e, stackTrace) { + _logManager.error('Failed to set permission for user ID: $userId', e, stackTrace); + rethrow; + } + } +} + +/// Repository for cache data +class CacheRepository { + final Database _db; + final LogManager _logManager; + + /// Create a new cache repository + CacheRepository(this._db) : _logManager = LogManager.getInstance(); + + /// Create the cache table if it doesn't exist + void createTable() { + _db.execute(''' + CREATE TABLE IF NOT EXISTS cache_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT UNIQUE NOT NULL, + file_path TEXT NOT NULL, + title TEXT, + duration INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_accessed DATETIME DEFAULT CURRENT_TIMESTAMP + ) + '''); + } + + /// Add a song to the cache + Future addSong({ + required String url, + required String filePath, + required String title, + required int duration, + }) async { + try { + _db.execute( + ''' + INSERT INTO cache_entries (url, file_path, title, duration) + VALUES (?, ?, ?, ?) + ''', + [url, filePath, title, duration], + ); + + final id = _db.lastInsertRowId; + + return Song( + id: id, + url: url, + filePath: filePath, + title: title, + duration: duration, + addedAt: DateTime.now(), + ); + } catch (e, stackTrace) { + _logManager.error('Failed to add song to cache: $url', e, stackTrace); + rethrow; + } + } + + /// Get a song from the cache by URL + Future getSongByUrl(String url) async { + try { + final result = _db.select( + 'SELECT * FROM cache_entries WHERE url = ?', + [url], + ); + + if (result.isEmpty) { + return null; + } + + final row = result.first; + + return Song( + id: row['id'] as int, + url: row['url'] as String, + filePath: row['file_path'] as String, + title: row['title'] as String, + duration: row['duration'] as int, + addedAt: DateTime.parse(row['created_at'] as String), + ); + } catch (e, stackTrace) { + _logManager.error('Failed to get song by URL: $url', e, stackTrace); + return null; + } + } + + /// Get a song from the cache by ID + Future getSongById(int id) async { + try { + final result = _db.select( + 'SELECT * FROM cache_entries WHERE id = ?', + [id], + ); + + if (result.isEmpty) { + return null; + } + + final row = result.first; + + return Song( + id: row['id'] as int, + url: row['url'] as String, + filePath: row['file_path'] as String, + title: row['title'] as String, + duration: row['duration'] as int, + addedAt: DateTime.parse(row['created_at'] as String), + ); + } catch (e, stackTrace) { + _logManager.error('Failed to get song by ID: $id', e, stackTrace); + return null; + } + } + + /// Update the last accessed time for a song + Future updateSongLastAccessed(int id) async { + try { + _db.execute( + 'UPDATE cache_entries SET last_accessed = CURRENT_TIMESTAMP WHERE id = ?', + [id], + ); + } catch (e, stackTrace) { + _logManager.error('Failed to update last accessed time for song ID: $id', e, stackTrace); + rethrow; + } + } + + /// Remove a song from the cache + Future removeSong(int id) async { + try { + _db.execute( + 'DELETE FROM cache_entries WHERE id = ?', + [id], + ); + } catch (e, stackTrace) { + _logManager.error('Failed to remove song from cache: $id', e, stackTrace); + rethrow; + } + } + + /// Get all songs in the cache + Future> getAllSongs() async { + try { + final result = _db.select('SELECT * FROM cache_entries ORDER BY last_accessed DESC'); + + return result.map((row) => Song( + id: row['id'] as int, + url: row['url'] as String, + filePath: row['file_path'] as String, + title: row['title'] as String, + duration: row['duration'] as int, + addedAt: DateTime.parse(row['created_at'] as String), + )).toList(); + } catch (e, stackTrace) { + _logManager.error('Failed to get all songs from cache', e, stackTrace); + return []; + } + } + + /// Clear all songs from the cache + Future clearAllSongs() async { + try { + _db.execute('DELETE FROM cache_entries'); + } catch (e, stackTrace) { + _logManager.error('Failed to clear all songs from cache', e, stackTrace); + rethrow; + } + } +} + +/// Class for managing database connections +class DatabaseManager { + final String _dbPath; + late Database _db; + + late UserRepository users; + late PermissionRepository permissions; + late CacheRepository cache; + + final LogManager _logManager; + + /// Create a new database manager + DatabaseManager(this._dbPath) : _logManager = LogManager.getInstance() { + _initialize(); + } + + /// Initialize the database + void _initialize() { + try { + // Create the database directory if it doesn't exist + final dbDir = path.dirname(_dbPath); + if (!Directory(dbDir).existsSync()) { + Directory(dbDir).createSync(recursive: true); + } + + // Open the database + _db = sqlite3.open(_dbPath); + + // Initialize repositories + users = UserRepository(_db); + permissions = PermissionRepository(_db); + cache = CacheRepository(_db); + + // Create tables + users.createTable(); + permissions.createTables(); + cache.createTable(); + + _logManager.info('Database initialized: $_dbPath'); + } catch (e, stackTrace) { + _logManager.error('Failed to initialize database', e, stackTrace); + rethrow; + } + } + + /// Close the database connection + void close() { + _db.dispose(); + } +} \ No newline at end of file diff --git a/lib/src/storage/permission_manager.dart b/lib/src/storage/permission_manager.dart new file mode 100644 index 0000000..383f3c5 --- /dev/null +++ b/lib/src/storage/permission_manager.dart @@ -0,0 +1,130 @@ +import 'dart:async'; + +import 'package:mumbullet/src/config/config.dart'; +import 'package:mumbullet/src/logging/logger.dart'; +import 'package:mumbullet/src/mumble/models.dart'; +import 'package:mumbullet/src/storage/database.dart'; + +/// Permission levels +class PermissionLevel { + static const int none = 0; + static const int view = 1; + static const int readWrite = 2; + static const int admin = 3; +} + +/// Class for managing user permissions +class PermissionManager { + final DatabaseManager _db; + final BotConfig _config; + final LogManager _logManager; + + /// Create a new permission manager + PermissionManager(this._db, this._config) : _logManager = LogManager.getInstance(); + + /// Get the permission level for a user + Future getPermissionLevel(MumbleUser user) async { + try { + // Get user from database + final dbUser = await _db.users.getUserByName(user.name); + + if (dbUser == null) { + // Auto-create user with default permission + _logManager.info('Auto-creating user: ${user.name}'); + final newUser = await _db.users.createUser(user.name); + await _db.permissions.setUserPermission( + newUser.id, + _config.defaultPermissionLevel, + ); + return _config.defaultPermissionLevel; + } + + // Get permission level + final permission = await _db.permissions.getUserPermission(dbUser.id); + return permission ?? _config.defaultPermissionLevel; + } catch (e, stackTrace) { + _logManager.error('Failed to get permission level for ${user.name}', e, stackTrace); + // Default to no access on error + return PermissionLevel.none; + } + } + + /// Set the permission level for a user + Future setPermissionLevel(String username, int level) async { + try { + // Get user from database + final dbUser = await _db.users.getUserByName(username); + + if (dbUser == null) { + _logManager.warning('Cannot set permission for non-existent user: $username'); + return false; + } + + // Validate permission level + if (level < PermissionLevel.none || level > PermissionLevel.admin) { + _logManager.warning('Invalid permission level: $level'); + return false; + } + + // Set permission level + await _db.permissions.setUserPermission(dbUser.id, level); + _logManager.info('Set permission level for ${dbUser.username} to $level'); + return true; + } catch (e, stackTrace) { + _logManager.error('Failed to set permission level for $username', e, stackTrace); + return false; + } + } + + /// Get all users with their permission levels + Future> getAllUserPermissions() async { + try { + final result = {}; + + // Get all users + final users = await _db.users.getAllUsers(); + + // Get permission for each user + for (final user in users) { + final permission = await _db.permissions.getUserPermission(user.id); + result[user.username] = permission ?? _config.defaultPermissionLevel; + } + + return result; + } catch (e, stackTrace) { + _logManager.error('Failed to get all user permissions', e, stackTrace); + return {}; + } + } + + /// Create a new user with the given permission level + Future createUser(String username, int level) async { + try { + // Check if user already exists + final existingUser = await _db.users.getUserByName(username); + + if (existingUser != null) { + _logManager.warning('User already exists: $username'); + return false; + } + + // Validate permission level + if (level < PermissionLevel.none || level > PermissionLevel.admin) { + _logManager.warning('Invalid permission level: $level'); + return false; + } + + // Create user + final newUser = await _db.users.createUser(username); + + // Set permission level + await _db.permissions.setUserPermission(newUser.id, level); + + _logManager.info('Created user $username with permission level $level'); + return true; + } catch (e, stackTrace) { + _logManager.error('Failed to create user $username', e, stackTrace); + return false; + } + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 7c25378..6464274 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -18,7 +18,7 @@ packages: source: hosted version: "7.4.5" args: - dependency: transitive + dependency: "direct main" description: name: args sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 @@ -41,6 +41,78 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "7cf79af8eb6023bee797a77b067fb6e63ac5650f3789546e023958098feb776e" + url: "https://pub.dev" + source: hosted + version: "2.5.2" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" + url: "https://pub.dev" + source: hosted + version: "4.0.4" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: "7a507e6026abe52074836d51a945bfad456daa7493eb7a6cac565e490e7d5b54" + url: "https://pub.dev" + source: hosted + version: "2.5.2" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "1ce1e5063b564f26c27bda54c82a3d38339df69ec58f90e0017f447de77e4839" + url: "https://pub.dev" + source: hosted + version: "2.5.2" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "564230f3fd9363df7870058fef11ec5502ee620aec3b1ee8106b943be5c63a76" + url: "https://pub.dev" + source: hosted + version: "9.1.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "082001b5c3dc495d4a42f1d5789990505df20d8547d42507c29050af6933ee27" + url: "https://pub.dev" + source: hosted + version: "8.10.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" cli_config: dependency: transitive description: @@ -49,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + url: "https://pub.dev" + source: hosted + version: "4.10.1" collection: dependency: transitive description: @@ -74,13 +154,37 @@ packages: source: hosted version: "1.14.1" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" url: "https://pub.dev" source: hosted version: "3.0.6" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "5b236382b47ee411741447c1f1e111459c941ea1b3f2b540dde54c210a3662af" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + dumble: + dependency: "direct main" + description: + name: dumble + sha256: "7b06fe246b185d180282c6304b89869f11897243474f52882c1ba7b0efc53838" + url: "https://pub.dev" + source: hosted + version: "0.8.9" + ffi: + dependency: transitive + description: + name: ffi + sha256: "13a6ccf6a459a125b3fcdb6ec73bd5ff90822e071207c663bfd1f70062d51d18" + url: "https://pub.dev" + source: hosted + version: "1.2.1" file: dependency: transitive description: @@ -89,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" frontend_server_client: dependency: transitive description: @@ -105,6 +217,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + http: + dependency: "direct main" + description: + name: http + sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + http_methods: + dependency: transitive + description: + name: http_methods + sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566" + url: "https://pub.dev" + source: hosted + version: "1.1.1" http_multi_server: dependency: transitive description: @@ -137,6 +273,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.2" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c + url: "https://pub.dev" + source: hosted + version: "6.9.5" lints: dependency: "direct dev" description: @@ -146,7 +298,7 @@ packages: source: hosted version: "5.1.1" logging: - dependency: transitive + dependency: "direct main" description: name: logging sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 @@ -177,6 +329,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mockito: + dependency: "direct dev" + description: + name: mockito + sha256: "4546eac99e8967ea91bae633d2ca7698181d008e95fa4627330cf903d573277a" + url: "https://pub.dev" + source: hosted + version: "5.4.6" node_preamble: dependency: transitive description: @@ -185,6 +345,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + opus_dart: + dependency: "direct main" + description: + name: opus_dart + sha256: bf2e1f35b498c60e555fc14b84e54878e12cd66c8390e2b1f2170faacdd86640 + url: "https://pub.dev" + source: hosted + version: "2.0.1" package_config: dependency: transitive description: @@ -194,13 +362,21 @@ packages: source: hosted version: "2.2.0" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted version: "1.9.1" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.dev" + source: hosted + version: "3.9.1" pool: dependency: transitive description: @@ -209,6 +385,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: "01dd9bd0fa02548bf2ceee13545d4a0ec6046459d847b6b061d8a27237108a08" + url: "https://pub.dev" + source: hosted + version: "2.1.0" pub_semver: dependency: transitive description: @@ -217,8 +401,16 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - shelf: + pubspec_parse: dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + shelf: + dependency: "direct main" description: name: shelf sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 @@ -233,8 +425,16 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + shelf_router: + dependency: "direct main" + description: + name: shelf_router + sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864 + url: "https://pub.dev" + source: hosted + version: "1.1.4" shelf_static: - dependency: transitive + dependency: "direct main" description: name: shelf_static sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 @@ -249,6 +449,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" + url: "https://pub.dev" + source: hosted + version: "1.3.5" source_map_stack_trace: dependency: transitive description: @@ -273,6 +489,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sqlite3: + dependency: "direct main" + description: + name: sqlite3 + sha256: c0503c69b44d5714e6abbf4c1f51a3c3cc42b75ce785f44404765e4635481d38 + url: "https://pub.dev" + source: hosted + version: "2.7.6" stack_trace: dependency: transitive description: @@ -289,6 +513,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -329,6 +561,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.11" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" typed_data: dependency: transitive description: @@ -394,4 +634,4 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.7.3 <4.0.0" + dart: ">=3.8.0 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 3040047..5f7a5a6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,15 +1,28 @@ name: mumbullet -description: A sample command-line application. +description: A Dart-based music bot that connects to Mumble servers and plays audio from URLs. version: 1.0.0 # repository: https://github.com/my_org/my_repo environment: sdk: ^3.7.3 -# Add regular dependencies here. dependencies: - # path: ^1.8.0 + dumble: ^0.8.9 + shelf: ^1.4.0 + shelf_router: ^1.1.0 + shelf_static: ^1.1.0 + sqlite3: ^2.1.0 + crypto: ^3.0.3 + path: ^1.8.3 + logging: ^1.2.0 + args: ^2.4.0 + json_annotation: ^4.8.0 + http: ^1.2.0 + opus_dart: ^2.0.1 dev_dependencies: lints: ^5.0.0 test: ^1.24.0 + json_serializable: ^6.6.0 + build_runner: ^2.4.0 + mockito: ^5.4.2 diff --git a/scripts/dev-server.sh b/scripts/dev-server.sh new file mode 100644 index 0000000..2210fac --- /dev/null +++ b/scripts/dev-server.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +set -e + +# Script to manage MumBullet development server +# Usage: ./scripts/dev-server.sh [start|stop|restart|status] + +PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +COMPOSE_FILE="$PROJECT_DIR/dev-compose.yml" +DEV_CONFIG="$PROJECT_DIR/dev-config.json" + +# Check if Docker is running +check_docker() { + if ! docker info > /dev/null 2>&1; then + echo "Error: Docker is not running. Please start Docker and try again." + exit 1 + fi +} + +# Start the development server +start_server() { + echo "Starting Mumble development server..." + docker-compose -f "$COMPOSE_FILE" up -d + echo "Development server started at localhost:64738" + echo "Server password: devpass" + echo "Admin password: devsecret" + echo "" + echo "Run the bot with:" + echo "dart run bin/mumbullet.dart --config $DEV_CONFIG" +} + +# Stop the development server +stop_server() { + echo "Stopping Mumble development server..." + docker-compose -f "$COMPOSE_FILE" down + echo "Development server stopped" +} + +# Restart the development server +restart_server() { + echo "Restarting Mumble development server..." + docker-compose -f "$COMPOSE_FILE" restart + echo "Development server restarted" +} + +# Check the status of the development server +server_status() { + echo "Mumble development server status:" + docker-compose -f "$COMPOSE_FILE" ps +} + +# Create development config if it doesn't exist +ensure_dev_config() { + if [ ! -f "$DEV_CONFIG" ]; then + echo "Creating development configuration file..." + cat > "$DEV_CONFIG" << EOF +{ + "mumble": { + "server": "localhost", + "port": 64738, + "username": "MumBullet", + "password": "devpass", + "channel": "Music" + }, + "bot": { + "command_prefix": "!", + "default_permission_level": 0, + "max_queue_size": 50, + "cache_directory": "./dev-cache", + "max_cache_size_gb": 5 + }, + "dashboard": { + "port": 8080, + "admin_username": "admin", + "admin_password": "admin" + } +} +EOF + echo "Created development configuration at $DEV_CONFIG" + fi + + # Ensure cache directory exists + mkdir -p "$PROJECT_DIR/dev-cache" +} + +# Main script logic +check_docker +ensure_dev_config + +case "$1" in + start) + start_server + ;; + stop) + stop_server + ;; + restart) + restart_server + ;; + status) + server_status + ;; + *) + echo "Usage: $0 [start|stop|restart|status]" + echo "" + echo " start - Start the Mumble development server" + echo " stop - Stop the Mumble development server" + echo " restart - Restart the Mumble development server" + echo " status - Show the Mumble development server status" + exit 1 + ;; +esac + +exit 0 \ No newline at end of file diff --git a/scripts/start_dev_environment.sh b/scripts/start_dev_environment.sh new file mode 100755 index 0000000..effe30f --- /dev/null +++ b/scripts/start_dev_environment.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -e + +echo "Starting development environment for MumBullet" + +# Check if Docker is running +if ! docker info > /dev/null 2>&1; then + echo "Error: Docker is not running. Please start Docker and try again." + exit 1 +fi + +# Start Docker Compose services +cd /home/nate/source/non-work/mumbullet +docker-compose up -d + +echo "Development environment started. You can now run the bot with:" +echo "dart run bin/mumbullet.dart --config test/fixtures/test_config.json" +echo "" +echo "To stop the environment, run: docker-compose down" \ No newline at end of file diff --git a/test/audio/downloader_simple_test.dart b/test/audio/downloader_simple_test.dart new file mode 100644 index 0000000..edcf8b6 --- /dev/null +++ b/test/audio/downloader_simple_test.dart @@ -0,0 +1,210 @@ +import 'dart:io'; +import 'package:test/test.dart'; +import 'package:path/path.dart' as path; +import 'package:mumbullet/src/audio/downloader.dart'; +import 'package:mumbullet/src/config/config.dart'; +import 'package:mumbullet/src/storage/database.dart'; + +void main() { + group('YoutubeDownloader Simple Integration Tests', () { + late YoutubeDownloader downloader; + late DatabaseManager database; + late BotConfig config; + late Directory tempDir; + late String tempDbPath; + + setUpAll(() async { + // Check if yt-dlp is available + try { + final result = await Process.run('yt-dlp', ['--version']); + if (result.exitCode != 0) { + throw Exception('yt-dlp not available'); + } + } catch (e) { + print('Skipping yt-dlp tests: yt-dlp not found in PATH'); + return; + } + }); + + setUp(() async { + // Create temporary directory for cache + tempDir = await Directory.systemTemp.createTemp('mumbullet_test_'); + + // Create temporary database + tempDbPath = path.join(tempDir.path, 'test.db'); + + // Create test configuration + config = BotConfig( + commandPrefix: '!', + defaultPermissionLevel: 1, + maxQueueSize: 10, + cacheDirectory: tempDir.path, + maxCacheSizeGb: 0.1, // 100MB for testing + ); + + // Initialize database + database = DatabaseManager(tempDbPath); + + // Create downloader + downloader = YoutubeDownloader(config, database); + }); + + tearDown(() async { + // Clean up + try { + database.close(); + } catch (e) { + // Ignore close errors + } + if (tempDir.existsSync()) { + await tempDir.delete(recursive: true); + } + }); + + group('Basic Functionality', () { + test('should download a valid YouTube video and return Song object', () async { + const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; // "Me at the zoo" + + final song = await downloader.download(testUrl); + + // Verify basic song properties + expect(song.url, equals(testUrl)); + expect(song.title, isNotEmpty); + expect(song.title, isNot(equals('Unknown Title'))); + expect(song.duration, greaterThan(0)); + expect(song.filePath, isNotEmpty); + expect(song.id, greaterThan(0)); + expect(song.addedAt, isNotNull); + + // Verify file path structure + expect(song.filePath, startsWith(tempDir.path)); + expect(song.filePath, endsWith('.wav')); + + print('Downloaded: ${song.title} (${song.duration}s) -> ${song.filePath}'); + }, timeout: Timeout(Duration(minutes: 2))); + + test('should use cache on second download', () async { + const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; + + // First download + final song1 = await downloader.download(testUrl); + + // Second download (should use cache) + final song2 = await downloader.download(testUrl); + + // Should return same song from cache + expect(song1.id, equals(song2.id)); + expect(song1.title, equals(song2.title)); + expect(song1.filePath, equals(song2.filePath)); + + print('Cache test passed: ${song1.title}'); + }, timeout: Timeout(Duration(minutes: 3))); + + test('should handle invalid URLs gracefully', () async { + final invalidUrls = [ + 'not-a-url', + 'https://invalid-domain.com/video', + 'https://www.youtube.com/watch?v=InvalidVideoId123', + ]; + + for (final url in invalidUrls) { + try { + await downloader.download(url); + fail('Should have thrown exception for invalid URL: $url'); + } catch (e) { + expect(e, isA()); + print('Correctly handled invalid URL: $url - ${e.toString()}'); + } + } + }); + + test('should provide cache statistics', () async { + // Initially empty + final initialStats = await downloader.getCacheStats(); + expect(initialStats['songCount'], equals(0)); + + // Download a video + const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; + await downloader.download(testUrl); + + // Check updated stats + final updatedStats = await downloader.getCacheStats(); + expect(updatedStats['songCount'], equals(1)); + expect(updatedStats['totalSizeBytes'], greaterThan(0)); + + print('Cache stats: ${updatedStats['songCount']} songs, ${updatedStats['totalSizeMb']} MB'); + }, timeout: Timeout(Duration(minutes: 2))); + + test('should clear cache', () async { + // Download a video + const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; + await downloader.download(testUrl); + + // Verify cache has content + final statsBefore = await downloader.getCacheStats(); + expect(statsBefore['songCount'], greaterThan(0)); + + // Clear cache + await downloader.clearCache(); + + // Verify cache is empty + final statsAfter = await downloader.getCacheStats(); + expect(statsAfter['songCount'], equals(0)); + expect(statsAfter['totalSizeBytes'], equals(0)); + + print('Cache cleared successfully'); + }, timeout: Timeout(Duration(minutes: 2))); + }); + + group('URL Format Variations', () { + test('should handle different YouTube URL formats', () async { + final testUrls = [ + 'https://www.youtube.com/watch?v=jNQXAC9IVRw', + 'https://youtu.be/jNQXAC9IVRw', + ]; + + String? firstTitle; + + for (final url in testUrls) { + final song = await downloader.download(url); + + expect(song.title, isNotEmpty); + expect(song.duration, greaterThan(0)); + + if (firstTitle == null) { + firstTitle = song.title; + } else { + // All URLs should resolve to the same video + expect(song.title, equals(firstTitle)); + } + + print('URL format test: $url -> ${song.title}'); + } + }, timeout: Timeout(Duration(minutes: 3))); + }); + + group('Edge Cases', () { + test('should handle re-download when cached file is missing', () async { + const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; + + // Download a video + final song1 = await downloader.download(testUrl); + final originalPath = song1.filePath; + + // Manually delete the file (simulating external deletion) + if (File(originalPath).existsSync()) { + await File(originalPath).delete(); + } + + // Try to download again - should re-download + final song2 = await downloader.download(testUrl); + + // Should have same title but may have different file path + expect(song2.title, equals(song1.title)); + expect(song2.filePath, isNotEmpty); + + print('Re-download test passed: ${song2.title}'); + }, timeout: Timeout(Duration(minutes: 3))); + }); + }, skip: Platform.environment['SKIP_INTEGRATION_TESTS'] == 'true'); +} diff --git a/test/audio/downloader_test.dart b/test/audio/downloader_test.dart new file mode 100644 index 0000000..80cc80f --- /dev/null +++ b/test/audio/downloader_test.dart @@ -0,0 +1,342 @@ +import 'dart:io'; +import 'dart:math'; +import 'package:test/test.dart'; +import 'package:path/path.dart' as path; +import 'package:mumbullet/src/audio/downloader.dart'; +import 'package:mumbullet/src/config/config.dart'; +import 'package:mumbullet/src/storage/database.dart'; + +void main() { + group('YoutubeDownloader Integration Tests', () { + late YoutubeDownloader downloader; + late DatabaseManager database; + late BotConfig config; + late Directory tempDir; + late String tempDbPath; + + setUpAll(() async { + // Check if yt-dlp is available + try { + final result = await Process.run('yt-dlp', ['--version']); + if (result.exitCode != 0) { + throw Exception('yt-dlp not available'); + } + } catch (e) { + print('Skipping yt-dlp tests: yt-dlp not found in PATH'); + return; + } + }); + + setUp(() async { + // Create temporary directory for cache + tempDir = await Directory.systemTemp.createTemp('mumbullet_test_'); + + // Create temporary database + tempDbPath = path.join(tempDir.path, 'test.db'); + + // Create test configuration + config = BotConfig( + commandPrefix: '!', + defaultPermissionLevel: 1, + maxQueueSize: 10, + cacheDirectory: tempDir.path, + maxCacheSizeGb: 0.001, // 1MB for testing + ); + + // Initialize database + database = DatabaseManager(tempDbPath); + + // Create downloader + downloader = YoutubeDownloader(config, database); + }); + + tearDown(() async { + // Clean up + database.close(); + if (tempDir.existsSync()) { + await tempDir.delete(recursive: true); + } + }); + + group('Valid YouTube URL Downloads', () { + test('should download a valid YouTube video', () async { + // Using a short, stable YouTube video for testing + const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; // "Me at the zoo" - first YouTube video + + final song = await downloader.download(testUrl); + + // Verify song object + expect(song.url, equals(testUrl)); + expect(song.title, isNotEmpty); + expect(song.duration, greaterThan(0)); + expect(song.filePath, isNotEmpty); + expect(song.id, greaterThan(0)); + + // Verify file exists (check all .wav files in cache directory) + final cacheDir = Directory(tempDir.path); + final wavFiles = cacheDir.listSync().where((f) => f.path.endsWith('.wav')).toList(); + expect(wavFiles.length, greaterThan(0)); + + // Find the actual downloaded file + final actualFile = wavFiles.firstWhere((f) => File(f.path).existsSync()); + expect(File(actualFile.path).lengthSync(), greaterThan(0)); + + // Verify file is in cache directory + expect(song.filePath, startsWith(tempDir.path)); + expect(song.filePath, endsWith('.wav')); + }, timeout: Timeout(Duration(minutes: 2))); + + test('should use cached version on second download', () async { + const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; + + // First download + final stopwatch1 = Stopwatch()..start(); + final song1 = await downloader.download(testUrl); + stopwatch1.stop(); + + // Second download (should use cache) + final stopwatch2 = Stopwatch()..start(); + final song2 = await downloader.download(testUrl); + stopwatch2.stop(); + + // Verify same song returned + expect(song1.id, equals(song2.id)); + expect(song1.filePath, equals(song2.filePath)); + expect(song1.title, equals(song2.title)); + + // Second call should be much faster (cached) + expect(stopwatch2.elapsedMilliseconds, lessThan(stopwatch1.elapsedMilliseconds ~/ 2)); + + // File should still exist + final file = File(song2.filePath); + expect(file.existsSync(), isTrue); + }, timeout: Timeout(Duration(minutes: 3))); + + test('should handle different YouTube URL formats', () async { + final testUrls = [ + 'https://www.youtube.com/watch?v=jNQXAC9IVRw', + 'https://youtube.com/watch?v=jNQXAC9IVRw', + 'https://youtu.be/jNQXAC9IVRw', + 'https://m.youtube.com/watch?v=jNQXAC9IVRw', + ]; + + final songs = []; + + for (final url in testUrls) { + final song = await downloader.download(url); + songs.add(song); + + expect(song.title, isNotEmpty); + expect(song.duration, greaterThan(0)); + + final file = File(song.filePath); + expect(file.existsSync(), isTrue); + } + + // All should have the same title (same video) + final firstTitle = songs.first.title; + for (final song in songs) { + expect(song.title, equals(firstTitle)); + } + }, timeout: Timeout(Duration(minutes: 5))); + }); + + group('Invalid URL Handling', () { + test('should throw exception for malformed URLs', () async { + final invalidUrls = [ + 'not-a-url', + 'https://invalid-domain.com/video', + 'https://youtube.com/invalid-path', + 'https://www.youtube.com/watch?v=', + '', + ]; + + for (final url in invalidUrls) { + expect( + () => downloader.download(url), + throwsException, + reason: 'Should throw exception for invalid URL: $url', + ); + } + }); + + test('should throw exception for non-existent YouTube video', () async { + // Using a clearly non-existent video ID + const invalidUrl = 'https://www.youtube.com/watch?v=ThisVideoDoesNotExist123456789'; + + expect( + () => downloader.download(invalidUrl), + throwsException, + ); + }); + + test('should throw exception for private/deleted videos', () async { + // This video ID is known to be private/deleted + const privateUrl = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ_invalid'; + + expect( + () => downloader.download(privateUrl), + throwsException, + ); + }); + + test('should handle network timeouts gracefully', () async { + // Using a URL that will likely timeout or fail + const slowUrl = 'https://httpstat.us/200?sleep=30000'; // 30 second delay + + expect( + () => downloader.download(slowUrl), + throwsException, + ); + }, timeout: Timeout(Duration(seconds: 10))); + }); + + group('Cache Management', () { + test('should enforce cache size limits', () async { + // Set very small cache limit + final smallConfig = BotConfig( + commandPrefix: '!', + defaultPermissionLevel: 1, + maxQueueSize: 10, + cacheDirectory: tempDir.path, + maxCacheSizeGb: 0.000001, // ~1KB - very small + ); + + final smallCacheDownloader = YoutubeDownloader(smallConfig, database); + + // Download a video (will exceed tiny cache limit) + const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; + final song = await smallCacheDownloader.download(testUrl); + + // Verify file was created + expect(File(song.filePath).existsSync(), isTrue); + + // Cache enforcement should have run (no exception thrown) + final stats = await smallCacheDownloader.getCacheStats(); + expect(stats['songCount'], greaterThanOrEqualTo(0)); + }, timeout: Timeout(Duration(minutes: 2))); + + test('should clear cache completely', () async { + const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; + + // Download a video + final song = await downloader.download(testUrl); + expect(File(song.filePath).existsSync(), isTrue); + + // Verify cache has content + final statsBefore = await downloader.getCacheStats(); + expect(statsBefore['songCount'], greaterThan(0)); + + // Clear cache + await downloader.clearCache(); + + // Verify cache is empty + final statsAfter = await downloader.getCacheStats(); + expect(statsAfter['songCount'], equals(0)); + expect(statsAfter['totalSizeBytes'], equals(0)); + + // Verify file was deleted + expect(File(song.filePath).existsSync(), isFalse); + }, timeout: Timeout(Duration(minutes: 2))); + + test('should provide accurate cache statistics', () async { + // Initially empty + final initialStats = await downloader.getCacheStats(); + expect(initialStats['songCount'], equals(0)); + expect(initialStats['totalSizeBytes'], equals(0)); + + // Download a video + const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; + final song = await downloader.download(testUrl); + + // Check updated stats + final updatedStats = await downloader.getCacheStats(); + expect(updatedStats['songCount'], equals(1)); + expect(updatedStats['totalSizeBytes'], greaterThan(0)); + expect(updatedStats['totalSizeMb'], greaterThanOrEqualTo(0)); + + // Verify file size matches + final file = File(song.filePath); + expect(updatedStats['totalSizeBytes'], equals(file.lengthSync())); + }, timeout: Timeout(Duration(minutes: 2))); + + test('should handle missing cached files gracefully', () async { + const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; + + // Download a video + final song1 = await downloader.download(testUrl); + expect(File(song1.filePath).existsSync(), isTrue); + + // Manually delete the file (simulating external deletion) + await File(song1.filePath).delete(); + + // Try to download again - should re-download + final song2 = await downloader.download(testUrl); + + // Should create new file + expect(File(song2.filePath).existsSync(), isTrue); + expect(song2.title, equals(song1.title)); + + // May have different file path due to random naming + expect(song2.filePath, isNotEmpty); + }, timeout: Timeout(Duration(minutes: 3))); + }); + + group('Error Recovery', () { + test('should handle file system permission errors', () async { + // Create a read-only cache directory + final readOnlyDir = Directory(path.join(tempDir.path, 'readonly')); + await readOnlyDir.create(); + + // Make directory read-only (Unix-like systems) + if (!Platform.isWindows) { + await Process.run('chmod', ['444', readOnlyDir.path]); + } + + final restrictedConfig = BotConfig( + commandPrefix: '!', + defaultPermissionLevel: 1, + maxQueueSize: 10, + cacheDirectory: readOnlyDir.path, + maxCacheSizeGb: 1.0, + ); + + final restrictedDownloader = YoutubeDownloader(restrictedConfig, database); + + const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; + + // Should handle permission error gracefully + if (!Platform.isWindows) { + expect( + () => restrictedDownloader.download(testUrl), + throwsException, + ); + } + + // Restore permissions for cleanup + if (!Platform.isWindows) { + await Process.run('chmod', ['755', readOnlyDir.path]); + } + }, timeout: Timeout(Duration(minutes: 2))); + }); + + group('Metadata Extraction', () { + test('should extract correct metadata from YouTube video', () async { + const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; + + final song = await downloader.download(testUrl); + + // Verify metadata + expect(song.title, isNotEmpty); + expect(song.title, isNot(equals('Unknown Title'))); + expect(song.duration, greaterThan(0)); + expect(song.url, equals(testUrl)); + expect(song.addedAt, isNotNull); + + // Title should be reasonable (not just the URL) + expect(song.title.length, greaterThan(5)); + expect(song.title, isNot(contains('http'))); + }, timeout: Timeout(Duration(minutes: 2))); + }); + }, skip: Platform.environment['SKIP_INTEGRATION_TESTS'] == 'true'); +} diff --git a/test/command_handler_test.dart b/test/command_handler_test.dart new file mode 100644 index 0000000..8bb0b9e --- /dev/null +++ b/test/command_handler_test.dart @@ -0,0 +1,52 @@ +import 'package:test/test.dart'; + +void main() { + group('URL Extraction from HTML', () { + + test('should extract URL from HTML anchor tag', () { + // Access the private method through reflection or create a test helper + // For now, let's test the functionality indirectly by testing the regex pattern + final input = 'https://www.youtube.com/watch?v=dpx6L15oA4k'; + final aTagRegex = RegExp(r']*>.*?', caseSensitive: false); + final match = aTagRegex.firstMatch(input); + + expect(match, isNotNull); + expect(match!.group(1), equals('https://www.youtube.com/watch?v=dpx6L15oA4k')); + }); + + test('should return original string if not HTML anchor tag', () { + final input = 'https://www.youtube.com/watch?v=dpx6L15oA4k'; + final aTagRegex = RegExp(r']*>.*?', caseSensitive: false); + final match = aTagRegex.firstMatch(input); + + expect(match, isNull); + }); + + test('should handle various HTML anchor tag formats', () { + final testCases = [ + 'Link', + 'Link', + 'Link', + 'Link Text', + ]; + + final aTagRegex = RegExp(r']*>.*?', caseSensitive: false); + + for (final testCase in testCases) { + final match = aTagRegex.firstMatch(testCase); + expect(match, isNotNull, reason: 'Failed to match: $testCase'); + expect(match!.group(1), equals('https://example.com')); + } + }); + + test('should handle empty or whitespace input', () { + final testCases = ['', ' ', '\t\n']; + final aTagRegex = RegExp(r']*>.*?', caseSensitive: false); + + for (final testCase in testCases) { + final match = aTagRegex.firstMatch(testCase.trim()); + expect(match, isNull, reason: 'Should not match empty/whitespace: "$testCase"'); + } + }); + }); +} diff --git a/test/config_test.dart b/test/config_test.dart new file mode 100644 index 0000000..fb35910 --- /dev/null +++ b/test/config_test.dart @@ -0,0 +1,74 @@ +import 'dart:io'; + +import 'package:mumbullet/src/config/config.dart'; +import 'package:test/test.dart'; + +void main() { + group('AppConfig', () { + late File tempConfigFile; + + setUp(() { + tempConfigFile = File('test_config.json'); + tempConfigFile.writeAsStringSync(''' +{ + "mumble": { + "server": "test.server.com", + "port": 12345, + "username": "TestBot", + "password": "testpass", + "channel": "Root" + }, + "bot": { + "command_prefix": "?", + "default_permission_level": 1, + "max_queue_size": 100, + "cache_directory": "./test_cache", + "max_cache_size_gb": 2.5 + }, + "dashboard": { + "port": 9090, + "admin_username": "testadmin", + "admin_password": "testpassword" + } +} +'''); + }); + + tearDown(() { + if (tempConfigFile.existsSync()) { + tempConfigFile.deleteSync(); + } + }); + + test('should load configuration from file', () { + final config = AppConfig.fromFile(tempConfigFile.path); + + expect(config.mumble.server, equals('test.server.com')); + expect(config.mumble.port, equals(12345)); + expect(config.mumble.username, equals('TestBot')); + expect(config.mumble.password, equals('testpass')); + expect(config.mumble.channel, equals('TestChannel')); + + expect(config.bot.commandPrefix, equals('?')); + expect(config.bot.defaultPermissionLevel, equals(1)); + expect(config.bot.maxQueueSize, equals(100)); + expect(config.bot.cacheDirectory, equals('./test_cache')); + expect(config.bot.maxCacheSizeGb, equals(2.5)); + + expect(config.dashboard.port, equals(9090)); + expect(config.dashboard.adminUsername, equals('testadmin')); + expect(config.dashboard.adminPassword, equals('testpassword')); + }); + + test('should throw exception for non-existent file', () { + expect(() => AppConfig.fromFile('non_existent_file.json'), throwsA(isA())); + }); + + test('should validate configuration', () { + final config = AppConfig.fromFile(tempConfigFile.path); + + // Valid configuration should not throw + expect(() => config.validate(), returnsNormally); + }); + }); +} diff --git a/test/fixtures/mumble-server.conf b/test/fixtures/mumble-server.conf new file mode 100644 index 0000000..4e6b4ef --- /dev/null +++ b/test/fixtures/mumble-server.conf @@ -0,0 +1,71 @@ +# Mumble server configuration file for testing + +# Database configuration +database=/data/mumble-server.sqlite + +# Welcome message +welcometext="Welcome to the MumBullet Test Server" + +# Server password (empty for no password) +serverpassword=testpass + +# Maximum bandwidth (in bits per second) clients may use +bandwidth=128000 + +# Maximum number of concurrent clients allowed +users=100 + +# Regular expression used to validate channel names +channelname=[ \\-=\\w\\#\\[\\]\\{\\}\\(\\)\\@\\|]+ + +# Regular expression used to validate user names +username=[-=\\w\\[\\]\\{\\}\\(\\)\\@\\|\\.]+ + +# Maximum length of text messages in characters. 0 for no limit. +textmessagelength=5000 + +# Allow HTML in messages +allowhtml=true + +# Set autoban duration for failed attempts, in seconds +autobantime=300 + +# Set autoban attempts, set to 0 to disable +autobancount=10 + +# Set autoban timeframe, in seconds +autobantime=300 + +# Default channel when users join the server +defaultchannel=0 + +# Enable bonjour service discovery +bonjour=False + +# Maximum length of channel name +channellength=100 + +# Force clients to use certificate authentication +certrequired=False + +# Maximum number of channels allowed +channelcount=100 + +# How deep the channel hierarchy can be +channelnestinglimit=10 + +# Maximum number of channels to operate on in a single transaction +channelfilterlinks=100 + +# Send voice to all channels the user is linked to +allowping=true + +# User message limit per second +messagelimit=5 + +# Message burst limit per second +messageburst=10 + +# Create a Music channel +registerName=Music +registerPassword= \ No newline at end of file diff --git a/test/fixtures/murmur.ini b/test/fixtures/murmur.ini new file mode 100644 index 0000000..caaac19 --- /dev/null +++ b/test/fixtures/murmur.ini @@ -0,0 +1,69 @@ +# Mumble server configuration file for testing + +# Basic server settings +database=/var/lib/mumble-server/mumble-server.sqlite +welcometext="Welcome to the MumBullet Test Server
    This server is used for integration testing." +host=0.0.0.0 +port=64738 + +# Server access +serverpassword=serverpassword +users=100 +allowhtml=true +registerName=admin +registerPassword=adminpassword + +# Channels configuration +# Root channel is always created automatically +# These register channels on first run +registerName=Music +registerPassword= +position=0 + +registerName=General +registerPassword= +position=1 + +registerName=Gaming +registerPassword= +position=2 + +# Create subchannels +registerName=Music/Subroom1 +registerPassword= +position=0 + +registerName=Music/Subroom2 +registerPassword= +position=1 + +registerName=General/Subroom3 +registerPassword= +position=0 + +# Default channel (Root channel is always id 0) +defaultchannel=1 # Music channel + +# User settings +username=[-=\\w\\[\\]\\{\\}\\(\\)\\@\\|\\.]+ +textmessagelength=5000 +imagemessagelength=131072 +allowhtml=true + +# Bandwidth settings +bandwidth=128000 + +# Log settings +logfile= + +# Registration +# Allow users to register themselves +registerself=true +# Don't require registration to join +registerpassword=false + +# Security +# Automatically ban IPs with more than 10 failed attempts in 120 seconds for 300 seconds +autobanAttempts=10 +autobanTimeframe=120 +autobanTime=300 \ No newline at end of file diff --git a/test/fixtures/test_config.json b/test/fixtures/test_config.json new file mode 100644 index 0000000..c34c9b6 --- /dev/null +++ b/test/fixtures/test_config.json @@ -0,0 +1,21 @@ +{ + "mumble": { + "server": "localhost", + "port": 64738, + "username": "TestBot", + "password": "serverpassword", + "channel": "Music" + }, + "bot": { + "command_prefix": "!", + "default_permission_level": 0, + "max_queue_size": 50, + "cache_directory": "./test/fixtures/cache", + "max_cache_size_gb": 1 + }, + "dashboard": { + "port": 8081, + "admin_username": "testadmin", + "admin_password": "testpass" + } +} \ No newline at end of file diff --git a/test/fixtures/test_configs/admin_config.json b/test/fixtures/test_configs/admin_config.json new file mode 100644 index 0000000..417656f --- /dev/null +++ b/test/fixtures/test_configs/admin_config.json @@ -0,0 +1,21 @@ +{ + "mumble": { + "server": "localhost", + "port": 64738, + "username": "AdminBot", + "password": "serverpassword", + "channel": "Music" + }, + "bot": { + "command_prefix": "!", + "default_permission_level": 0, + "max_queue_size": 50, + "cache_directory": "./test/fixtures/cache", + "max_cache_size_gb": 1 + }, + "dashboard": { + "port": 8081, + "admin_username": "testadmin", + "admin_password": "testpass" + } +} \ No newline at end of file diff --git a/test/fixtures/test_configs/user_config.json b/test/fixtures/test_configs/user_config.json new file mode 100644 index 0000000..eb9be3d --- /dev/null +++ b/test/fixtures/test_configs/user_config.json @@ -0,0 +1,21 @@ +{ + "mumble": { + "server": "localhost", + "port": 64738, + "username": "UserBot", + "password": "serverpassword", + "channel": "General" + }, + "bot": { + "command_prefix": "?", + "default_permission_level": 1, + "max_queue_size": 30, + "cache_directory": "./test/fixtures/cache", + "max_cache_size_gb": 0.5 + }, + "dashboard": { + "port": 8082, + "admin_username": "user", + "admin_password": "userpass" + } +} \ No newline at end of file diff --git a/test/integration/README.md b/test/integration/README.md new file mode 100644 index 0000000..7dc213f --- /dev/null +++ b/test/integration/README.md @@ -0,0 +1,103 @@ +# MumBullet Integration Tests + +This directory contains integration tests for the MumBullet application. + +## Prerequisites + +- Docker and Docker Compose installed and running +- Dart SDK installed + +## Running Tests + +The integration tests will automatically: +1. Check if Docker is running +2. Start the required Docker containers +3. Run the tests +4. Clean up containers afterward + +To run all tests: + +```bash +dart test +``` + +To run only integration tests: + +```bash +dart test test/integration/ +``` + +To run a specific test: + +```bash +dart test test/integration/connection_test.dart +``` + +## Development Environment + +For development, use the main docker-compose.yml in the project root: + +```bash +# Start the development server +docker-compose up -d + +# Run the bot +dart run bin/mumbullet.dart --config test/fixtures/test_config.json + +# Stop the development server +docker-compose down +``` + +## Integration Test Environment + +The integration tests use a separate Docker Compose file: + +```bash +# Run tests (Docker is started automatically) +dart test test/integration/ + +# Manually start test environment if needed +docker-compose -f test/integration/docker-compose.test.yml up -d + +# Manually stop test environment +docker-compose -f test/integration/docker-compose.test.yml down -v +``` + +## Test Configuration + +The tests use special configuration files in the `test/fixtures/` directory: + +- `test_config.json`: Main test bot configuration +- `test_configs/admin_config.json`: Admin bot configuration +- `test_configs/user_config.json`: User bot configuration + +All configurations point to the Docker Mumble server running on localhost:64738. + +## Server Configuration + +The Docker-based Mumble server is pre-configured with: + +- Server password: `serverpassword` +- Multiple channels: `Music`, `General`, `Gaming`, and their subchannels +- Default channel: `Music` + +This setup allows testing different authentication and channel navigation scenarios. + +## Project Structure + +``` +test/ +├── fixtures/ # Test fixtures and configurations +│ ├── cache/ # Cache directory for tests +│ ├── murmur.ini # Mumble server configuration +│ ├── test_config.json # Main test configuration +│ └── test_configs/ # Additional test configurations +│ ├── admin_config.json +│ └── user_config.json +└── integration/ # Integration tests + ├── connection_test.dart # Tests for Mumble connection + ├── docker-compose.test.yml # Docker setup for integration tests + └── README.md # This file +``` + +The integration tests use a separate Docker Compose setup from the development environment to ensure consistent, isolated testing. \ No newline at end of file diff --git a/test/integration/command_downloader_test.dart b/test/integration/command_downloader_test.dart new file mode 100644 index 0000000..553ea11 --- /dev/null +++ b/test/integration/command_downloader_test.dart @@ -0,0 +1,320 @@ +import 'dart:io'; +import 'package:test/test.dart'; +import 'package:path/path.dart' as path; +import 'package:mumbullet/src/audio/downloader.dart'; +import 'package:mumbullet/src/command/command_handler.dart'; +import 'package:mumbullet/src/command/command_parser.dart'; +import 'package:mumbullet/src/config/config.dart'; +import 'package:mumbullet/src/queue/music_queue.dart'; +import 'package:mumbullet/src/storage/database.dart'; + +/// Mock command context for testing +class MockCommandContext { + final String args; + final int permissionLevel; + final List replies = []; + + MockCommandContext({ + required this.args, + this.permissionLevel = 2, + }); + + Future reply(String message) async { + replies.add(message); + } +} + +void main() { + group('Command Handler with Downloader Integration Tests', () { + late CommandHandler commandHandler; + late YoutubeDownloader downloader; + late DatabaseManager database; + late BotConfig config; + late Directory tempDir; + late String tempDbPath; + late CommandParser commandParser; + late MusicQueue musicQueue; + + setUpAll(() async { + // Check if yt-dlp is available + try { + final result = await Process.run('yt-dlp', ['--version']); + if (result.exitCode != 0) { + throw Exception('yt-dlp not available'); + } + } catch (e) { + print('Skipping yt-dlp integration tests: yt-dlp not found in PATH'); + return; + } + }); + + setUp(() async { + // Create temporary directory for cache + tempDir = await Directory.systemTemp.createTemp('mumbullet_cmd_test_'); + + // Create temporary database + tempDbPath = path.join(tempDir.path, 'test.db'); + + // Create test configuration + config = BotConfig( + commandPrefix: '!', + defaultPermissionLevel: 1, + maxQueueSize: 10, + cacheDirectory: tempDir.path, + maxCacheSizeGb: 0.1, // 100MB for testing + ); + + // Initialize components + database = DatabaseManager(tempDbPath); + downloader = YoutubeDownloader(config, database); + commandParser = CommandParser(); + musicQueue = MusicQueue(); + + // Create command handler + commandHandler = CommandHandler( + commandParser, + musicQueue, + downloader, + config, + ); + }); + + tearDown(() async { + // Clean up + database.close(); + if (tempDir.existsSync()) { + await tempDir.delete(recursive: true); + } + }); + + group('Play Command Integration', () { + test('should download and play valid YouTube URL', () async { + const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; + final context = MockCommandContext(args: testUrl); + + // Get the play command + final playCommand = commandParser.getCommand('play'); + expect(playCommand, isNotNull); + + // Execute play command + await playCommand!.execute(context); + + // Verify responses + expect(context.replies.length, equals(2)); + expect(context.replies[0], contains('Downloading audio from:')); + expect(context.replies[0], contains(testUrl)); + expect(context.replies[1], startsWith('Now playing:')); + + // Verify queue state + expect(musicQueue.currentSong, isNotNull); + expect(musicQueue.currentSong!.url, equals(testUrl)); + + // Verify file was downloaded + final file = File(musicQueue.currentSong!.filePath); + expect(file.existsSync(), isTrue); + }, timeout: Timeout(Duration(minutes: 2))); + + test('should handle HTML anchor tag URLs in play command', () async { + const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; + const htmlInput = 'YouTube Video'; + final context = MockCommandContext(args: htmlInput); + + final playCommand = commandParser.getCommand('play'); + await playCommand!.execute(context); + + // Should extract URL from HTML and download + expect(context.replies.length, equals(2)); + expect(context.replies[0], contains(testUrl)); + expect(context.replies[1], startsWith('Now playing:')); + + expect(musicQueue.currentSong, isNotNull); + expect(musicQueue.currentSong!.url, equals(testUrl)); + }, timeout: Timeout(Duration(minutes: 2))); + + test('should handle play command with invalid URL', () async { + const invalidUrl = 'https://www.youtube.com/watch?v=InvalidVideoId123'; + final context = MockCommandContext(args: invalidUrl); + + final playCommand = commandParser.getCommand('play'); + await playCommand!.execute(context); + + // Should show download attempt and then error + expect(context.replies.length, equals(2)); + expect(context.replies[0], contains('Downloading audio from:')); + expect(context.replies[1], startsWith('Failed to download or play')); + + // Queue should be empty + expect(musicQueue.currentSong, isNull); + }, timeout: Timeout(Duration(minutes: 1))); + }); + + group('Queue Command Integration', () { + test('should download and queue valid YouTube URL', () async { + const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; + final context = MockCommandContext(args: testUrl, permissionLevel: 1); + + final queueCommand = commandParser.getCommand('queue'); + expect(queueCommand, isNotNull); + + await queueCommand!.execute(context); + + // Verify responses + expect(context.replies.length, equals(2)); + expect(context.replies[0], contains('Adding to queue:')); + expect(context.replies[1], startsWith('Now playing:')); // First song starts playing + + // Verify queue state + expect(musicQueue.currentSong, isNotNull); + expect(musicQueue.currentSong!.url, equals(testUrl)); + }, timeout: Timeout(Duration(minutes: 2))); + + test('should queue multiple songs', () async { + const testUrls = [ + 'https://www.youtube.com/watch?v=jNQXAC9IVRw', + 'https://youtu.be/jNQXAC9IVRw', // Same video, different format + ]; + + for (int i = 0; i < testUrls.length; i++) { + final context = MockCommandContext(args: testUrls[i], permissionLevel: 1); + final queueCommand = commandParser.getCommand('queue'); + + await queueCommand!.execute(context); + + if (i == 0) { + // First song starts playing + expect(context.replies.last, startsWith('Now playing:')); + } else { + // Subsequent songs are queued (but may use cache) + expect(context.replies.last, anyOf( + startsWith('Now playing:'), // If cached, might start immediately + startsWith('Added to queue at position'), + )); + } + } + + // Verify queue has songs + final queue = musicQueue.getQueue(); + expect(queue.length, greaterThanOrEqualTo(1)); + }, timeout: Timeout(Duration(minutes: 3))); + + test('should handle queue command with invalid URL', () async { + const invalidUrl = 'not-a-valid-url'; + final context = MockCommandContext(args: invalidUrl, permissionLevel: 1); + + final queueCommand = commandParser.getCommand('queue'); + await queueCommand!.execute(context); + + // Should show error + expect(context.replies.length, equals(2)); + expect(context.replies[0], contains('Adding to queue:')); + expect(context.replies[1], startsWith('Failed to download or queue')); + + // Queue should be empty + expect(musicQueue.getQueue(), isEmpty); + }, timeout: Timeout(Duration(minutes: 1))); + }); + + group('Shuffle Command Integration', () { + test('should shuffle cached songs', () async { + // First, download a few songs to cache + const testUrls = [ + 'https://www.youtube.com/watch?v=jNQXAC9IVRw', + ]; + + // Download songs to cache + for (final url in testUrls) { + await downloader.download(url); + } + + // Now test shuffle command + final context = MockCommandContext(args: '', permissionLevel: 2); + final shuffleCommand = commandParser.getCommand('shuffle'); + + await shuffleCommand!.execute(context); + + // Should have shuffled and started playing + expect(context.replies.length, equals(1)); + expect(context.replies[0], contains('Added')); + expect(context.replies[0], contains('shuffled songs')); + expect(context.replies[0], contains('Now playing:')); + + // Queue should have songs + final queue = musicQueue.getQueue(); + expect(queue.length, greaterThan(0)); + expect(musicQueue.currentSong, isNotNull); + }, timeout: Timeout(Duration(minutes: 2))); + + test('should handle shuffle with no cached songs', () async { + // Clear any existing cache + await downloader.clearCache(); + + final context = MockCommandContext(args: '', permissionLevel: 2); + final shuffleCommand = commandParser.getCommand('shuffle'); + + await shuffleCommand!.execute(context); + + // Should indicate no cached songs + expect(context.replies.length, equals(1)); + expect(context.replies[0], contains('No cached songs found')); + }); + }); + + group('URL Format Handling', () { + test('should handle various YouTube URL formats in commands', () async { + final testCases = [ + 'https://www.youtube.com/watch?v=jNQXAC9IVRw', + 'https://youtube.com/watch?v=jNQXAC9IVRw', + 'https://youtu.be/jNQXAC9IVRw', + 'https://m.youtube.com/watch?v=jNQXAC9IVRw', + 'Video Link', + ]; + + for (final testUrl in testCases) { + // Clear queue for each test + musicQueue.clear(); + + final context = MockCommandContext(args: testUrl); + final playCommand = commandParser.getCommand('play'); + + await playCommand!.execute(context); + + // Should successfully download and play + expect(context.replies.length, equals(2)); + expect(context.replies[1], startsWith('Now playing:')); + expect(musicQueue.currentSong, isNotNull); + + // All should result in the same video title (same video) + expect(musicQueue.currentSong!.title, isNotEmpty); + } + }, timeout: Timeout(Duration(minutes: 5))); + }); + + group('Permission Handling', () { + test('should respect permission levels for commands', () async { + const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; + + // Test play command with insufficient permissions + final lowPermContext = MockCommandContext(args: testUrl, permissionLevel: 1); + final playCommand = commandParser.getCommand('play'); + + // Play command requires level 2, user has level 1 + expect(playCommand!.requiredPermissionLevel, equals(2)); + + // This would normally be handled by the command parser's permission check + // but we're testing the command directly, so we'll verify the requirement + expect(lowPermContext.permissionLevel, lessThan(playCommand.requiredPermissionLevel)); + + // Test queue command with sufficient permissions + final queueContext = MockCommandContext(args: testUrl, permissionLevel: 1); + final queueCommand = commandParser.getCommand('queue'); + + expect(queueCommand!.requiredPermissionLevel, equals(1)); + expect(queueContext.permissionLevel, greaterThanOrEqualTo(queueCommand.requiredPermissionLevel)); + + await queueCommand.execute(queueContext); + expect(queueContext.replies.length, equals(2)); + expect(queueContext.replies[1], startsWith('Now playing:')); + }, timeout: Timeout(Duration(minutes: 2))); + }); + }, skip: Platform.environment['SKIP_INTEGRATION_TESTS'] == 'true'); +} diff --git a/test/integration/connection_test.dart b/test/integration/connection_test.dart new file mode 100644 index 0000000..fb8c397 --- /dev/null +++ b/test/integration/connection_test.dart @@ -0,0 +1,272 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:mumbullet/mumbullet.dart'; +import 'package:test/test.dart'; + +/// Integration test for Mumble connection +/// +/// This test: +/// - Requires Docker to be running +/// - Uses a dedicated docker-compose.test.yml file +/// - Creates a Mumble server with predefined channels +/// - Tests connection, authentication, and channel navigation +/// +/// The server is automatically started before tests and stopped after tests +/// using docker-compose commands. +@Tags(['docker']) +void main() { + // Use setUpAll and tearDownAll to ensure Docker is only started/stopped once for all tests + late MumbleConnection connection; + late AppConfig config; + + const rootPath = '/home/nate/source/non-work/mumbullet'; + const configPath = '$rootPath/test/fixtures/test_config.json'; + + setUpAll(() async { + // Create cache directory if it doesn't exist + final cacheDir = Directory('$rootPath/test/fixtures/cache'); + if (!await cacheDir.exists()) { + await cacheDir.create(recursive: true); + } + + // Check if Docker is running + try { + final checkDocker = await Process.run('docker', ['info']); + if (checkDocker.exitCode != 0) { + fail('Docker is not running. Please start Docker and try again.'); + } + } catch (e) { + fail('Failed to check Docker status: $e. Is Docker installed?'); + } + + // Start Docker container using test-specific compose file + print('Starting Mumble server container for tests...'); + final dockerProcess = await Process.run( + 'docker-compose', + ['-f', 'test/integration/docker-compose.test.yml', 'up', '-d'], + workingDirectory: rootPath + ); + + if (dockerProcess.exitCode != 0) { + fail('Failed to start Docker container: ${dockerProcess.stderr}'); + } + + // Wait for server to start + print('Waiting for Mumble server to start...'); + await Future.delayed(Duration(seconds: 5)); + }); + + tearDownAll(() async { + // Stop Docker container + print('Stopping Mumble server container...'); + final stopProcess = await Process.run( + 'docker-compose', + ['-f', 'test/integration/docker-compose.test.yml', 'down', '-v'], + workingDirectory: rootPath + ); + + if (stopProcess.exitCode != 0) { + print('Warning: Failed to stop Docker container: ${stopProcess.stderr}'); + } + }); + + setUp(() async { + // Load test configuration + config = AppConfig.fromFile(configPath); + + // Create connection + connection = MumbleConnection(config.mumble); + }); + + tearDown(() async { + // Disconnect from server + if (connection.isConnected) { + await connection.disconnect(); + } + }); + + test('Should connect to Mumble server with password', () async { + // Setup test expectations + final connectionCompleter = Completer(); + + // Listen for connection state changes + connection.connectionState.listen((isConnected) { + if (isConnected && !connectionCompleter.isCompleted) { + connectionCompleter.complete(true); + } + }); + + // Attempt to connect with password + await connection.connect(); + + // Wait for connection or timeout + expect( + await connectionCompleter.future.timeout(Duration(seconds: 10), onTimeout: () => false), + isTrue, + reason: 'Failed to connect to Mumble server with password', + ); + + // Verify connection is established + expect(connection.isConnected, isTrue); + + // Verify we can get channel information + expect(connection.client, isNotNull); + expect(connection.client!.rootChannel, isNotNull); + + // Verify we can send a message + await connection.sendChannelMessage('Integration test message'); + }); + + test('Should join different channels', () async { + // Setup test expectations + final connectionCompleter = Completer(); + + // Listen for connection state changes + connection.connectionState.listen((isConnected) { + if (isConnected && !connectionCompleter.isCompleted) { + connectionCompleter.complete(true); + } + }); + + // Connect to server + await connection.connect(); + + // Wait for connection + expect(await connectionCompleter.future.timeout(Duration(seconds: 10), onTimeout: () => false), isTrue); + + expect(connection.client, isNotNull); + final channels = connection.client?.getChannels(); + print(channels?.values); + expect(channels, isNotNull); + expect(channels?.length, greaterThan(1)); + + // Test joining the Music channel + await connection.joinChannel('Music'); + expect(connection.currentChannel?.name, equals('Music')); + + // Test joining a subchannel + await connection.joinChannel('Subroom1'); + expect(connection.currentChannel?.name, equals('Subroom1')); + + // Test joining another top-level channel + await connection.joinChannel('General'); + expect(connection.currentChannel?.name, equals('General')); + + // Test joining the Gaming channel + await connection.joinChannel('Gaming'); + expect(connection.currentChannel?.name, equals('Gaming')); + + // Test joining the root channel + await connection.joinChannel(''); + expect(connection.currentChannel?.name, equals('Root')); + }); + + test('Should handle authentication failure', () async { + // Create a connection with incorrect password + final badConnection = MumbleConnection( + MumbleConfig( + server: config.mumble.server, + port: config.mumble.port, + username: config.mumble.username, + password: 'wrongpassword', + channel: config.mumble.channel, + ), + ); + + // Try to connect with wrong password + try { + await badConnection.connect(); + fail('Should have failed to connect with wrong password'); + } catch (e) { + // Expected failure + expect(badConnection.isConnected, isFalse); + } finally { + // Clean up + await badConnection.disconnect(); + } + }); + + // test('Should allow multiple bots to connect simultaneously', () async { + // // Load additional config + // final adminConfig = AppConfig.fromFile('${rootPath}/test/fixtures/test_configs/admin_config.json'); + // final userConfig = AppConfig.fromFile('${rootPath}/test/fixtures/test_configs/user_config.json'); + + // // Create additional connections + // final adminConnection = MumbleConnection(adminConfig.mumble); + // final userConnection = MumbleConnection(userConfig.mumble); + + // try { + // // Connect all bots + // await Future.wait([adminConnection.connect(), userConnection.connect()]); + + // // Verify all connections are established + // expect(adminConnection.isConnected, isTrue); + // expect(userConnection.isConnected, isTrue); + + // // Test sending messages from different bots + // await adminConnection.sendChannelMessage('Admin bot test message'); + // await userConnection.sendChannelMessage('User bot test message'); + + // // Test joining different channels with each bot + // await adminConnection.joinChannel('Gaming'); + // await userConnection.joinChannel('General'); + + // expect(adminConnection.currentChannel?.name, equals('Gaming')); + // expect(userConnection.currentChannel?.name, equals('General')); + // } finally { + // // Clean up + // await adminConnection.disconnect(); + // await userConnection.disconnect(); + // } + // }); + + test('Should handle reconnection', () async { + // Setup test expectations + final connectionCompleter = Completer(); + final reconnectionCompleter = Completer(); + var initiallyConnected = false; + + // Listen for connection state changes + connection.connectionState.listen((isConnected) { + if (isConnected && !initiallyConnected) { + initiallyConnected = true; + connectionCompleter.complete(true); + } else if (isConnected && initiallyConnected && !reconnectionCompleter.isCompleted) { + reconnectionCompleter.complete(true); + } + }); + + // Connect initially + await connection.connect(); + + // Wait for initial connection + expect( + await connectionCompleter.future.timeout(Duration(seconds: 10), onTimeout: () => false), + isTrue, + reason: 'Failed to connect to Mumble server initially', + ); + + // Restart the Mumble server + print('Restarting Mumble server container to test reconnection...'); + final restartProcess = await Process.run( + 'docker-compose', + ['-f', 'test/integration/docker-compose.test.yml', 'restart', 'mumble'], + workingDirectory: rootPath + ); + + if (restartProcess.exitCode != 0) { + fail('Failed to restart Mumble server: ${restartProcess.stderr}'); + } + + // Wait for reconnection + await expectLater( + reconnectionCompleter.future.timeout(Duration(seconds: 20), onTimeout: () => false), + isTrue, + reason: 'Failed to reconnect to Mumble server after restart', + ); + + // Verify connection is re-established + expect(connection.isConnected, isTrue); + }); +} diff --git a/test/integration/dart_test.yaml b/test/integration/dart_test.yaml new file mode 100644 index 0000000..17314ce --- /dev/null +++ b/test/integration/dart_test.yaml @@ -0,0 +1,7 @@ +tags: + docker: + # Integration tests that require Docker to be running + on_os: + - linux + - mac-os + timeout: 60s \ No newline at end of file diff --git a/test/integration/docker-compose.test.yml b/test/integration/docker-compose.test.yml new file mode 100644 index 0000000..7b011d2 --- /dev/null +++ b/test/integration/docker-compose.test.yml @@ -0,0 +1,20 @@ +services: + mumble: + image: mumblevoip/mumble-server:latest + container_name: mumble-server + restart: on-failure + ports: + - "64738:64738/udp" # Mumble voice port + - "64738:64738" # Web interface/WebRTC port (optional) + volumes: + - ../fixtures/murmur.ini:/etc/mumble-server.ini:ro + # Create a persistent volume for database + - mumble-test-data:/var/lib/mumble-server + environment: + - SUPERUSER_PASSWORD=supersecret + # We can still set environment variables to override config if needed + +volumes: + mumble-test-data: + # This ensures data is cleaned up when containers are removed + driver: local diff --git a/test/integration/downloader_integration_test.dart b/test/integration/downloader_integration_test.dart new file mode 100644 index 0000000..e139e8f --- /dev/null +++ b/test/integration/downloader_integration_test.dart @@ -0,0 +1,256 @@ +import 'dart:io'; +import 'package:test/test.dart'; +import 'package:path/path.dart' as path; +import 'package:mumbullet/src/audio/downloader.dart'; +import 'package:mumbullet/src/command/command_handler.dart'; +import 'package:mumbullet/src/config/config.dart'; +import 'package:mumbullet/src/storage/database.dart'; + +void main() { + group('Downloader Integration Tests', () { + late YoutubeDownloader downloader; + late DatabaseManager database; + late BotConfig config; + late Directory tempDir; + late String tempDbPath; + + setUpAll(() async { + // Check if yt-dlp is available + try { + final result = await Process.run('yt-dlp', ['--version']); + if (result.exitCode != 0) { + throw Exception('yt-dlp not available'); + } + } catch (e) { + print('Skipping yt-dlp tests: yt-dlp not found in PATH'); + return; + } + }); + + setUp(() async { + // Create temporary directory for cache + tempDir = await Directory.systemTemp.createTemp('mumbullet_integration_'); + + // Create temporary database + tempDbPath = path.join(tempDir.path, 'test.db'); + + // Create test configuration + config = BotConfig( + commandPrefix: '!', + defaultPermissionLevel: 1, + maxQueueSize: 10, + cacheDirectory: tempDir.path, + maxCacheSizeGb: 0.1, // 100MB for testing + ); + + // Initialize database + database = DatabaseManager(tempDbPath); + + // Create downloader + downloader = YoutubeDownloader(config, database); + }); + + tearDown(() async { + // Clean up + try { + database.close(); + } catch (e) { + // Ignore close errors + } + if (tempDir.existsSync()) { + await tempDir.delete(recursive: true); + } + }); + + group('Real YouTube Downloads', () { + test('should successfully download the test YouTube video', () async { + const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; // "Me at the zoo" + + final song = await downloader.download(testUrl); + + // Verify the download worked + expect(song.url, equals(testUrl)); + expect(song.title, equals('Me at the zoo')); + expect(song.duration, equals(19)); + expect(song.filePath, isNotEmpty); + expect(song.id, greaterThan(0)); + + // Verify the file exists and has content + final file = File(song.filePath); + expect(file.existsSync(), isTrue); + expect(file.lengthSync(), greaterThan(1000000)); // Should be > 1MB + + print('✓ Successfully downloaded: ${song.title} (${song.duration}s)'); + print(' File: ${song.filePath} (${(file.lengthSync() / 1024 / 1024).toStringAsFixed(1)} MB)'); + }, timeout: Timeout(Duration(minutes: 2))); + + test('should handle different YouTube URL formats for same video', () async { + final testUrls = [ + 'https://www.youtube.com/watch?v=jNQXAC9IVRw', + 'https://youtu.be/jNQXAC9IVRw', + 'https://m.youtube.com/watch?v=jNQXAC9IVRw', + ]; + + final songs = []; + + for (final url in testUrls) { + final song = await downloader.download(url); + songs.add(song); + + expect(song.title, equals('Me at the zoo')); + expect(song.duration, equals(19)); + + print('✓ URL format test: $url -> ${song.title}'); + } + + // All should have the same title and duration + for (final song in songs) { + expect(song.title, equals(songs.first.title)); + expect(song.duration, equals(songs.first.duration)); + } + }, timeout: Timeout(Duration(minutes: 4))); + + test('should properly cache downloads', () async { + const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; + + // First download - should take time + final stopwatch1 = Stopwatch()..start(); + final song1 = await downloader.download(testUrl); + stopwatch1.stop(); + + // Second download - should be much faster (cached) + final stopwatch2 = Stopwatch()..start(); + final song2 = await downloader.download(testUrl); + stopwatch2.stop(); + + // Verify same song returned + expect(song1.id, equals(song2.id)); + expect(song1.filePath, equals(song2.filePath)); + expect(song1.title, equals(song2.title)); + + // Second call should be much faster + expect(stopwatch2.elapsedMilliseconds, lessThan(stopwatch1.elapsedMilliseconds ~/ 3)); + + print('✓ Cache test: First download: ${stopwatch1.elapsedMilliseconds}ms, Second: ${stopwatch2.elapsedMilliseconds}ms'); + }, timeout: Timeout(Duration(minutes: 3))); + }); + + group('Error Handling', () { + test('should handle invalid YouTube URLs', () async { + final invalidUrls = [ + 'https://www.youtube.com/watch?v=InvalidVideoId123456', + 'https://www.youtube.com/watch?v=', + 'not-a-url-at-all', + 'https://invalid-domain.com/video', + ]; + + for (final url in invalidUrls) { + try { + await downloader.download(url); + fail('Should have thrown exception for invalid URL: $url'); + } catch (e) { + expect(e, isA()); + print('✓ Correctly rejected invalid URL: $url'); + } + } + }); + + test('should handle network issues gracefully', () async { + // This URL should timeout or fail + const problematicUrl = 'https://httpstat.us/500'; + + try { + await downloader.download(problematicUrl); + fail('Should have thrown exception for problematic URL'); + } catch (e) { + expect(e, isA()); + print('✓ Correctly handled network issue: ${e.toString()}'); + } + }, timeout: Timeout(Duration(seconds: 30))); + }); + + group('Cache Management', () { + test('should provide accurate cache statistics', () async { + // Initially empty + final initialStats = await downloader.getCacheStats(); + expect(initialStats['songCount'], equals(0)); + expect(initialStats['totalSizeBytes'], equals(0)); + + // Download a video + const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; + final song = await downloader.download(testUrl); + + // Check updated stats + final updatedStats = await downloader.getCacheStats(); + expect(updatedStats['songCount'], equals(1)); + expect(updatedStats['totalSizeBytes'], greaterThan(1000000)); // > 1MB + + // Verify file size matches + final file = File(song.filePath); + expect(updatedStats['totalSizeBytes'], equals(file.lengthSync())); + + print('✓ Cache stats: ${updatedStats['songCount']} songs, ${updatedStats['totalSizeMb']} MB'); + }, timeout: Timeout(Duration(minutes: 2))); + + test('should clear cache completely', () async { + // Download a video + const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; + final song = await downloader.download(testUrl); + final filePath = song.filePath; + + // Verify file exists + expect(File(filePath).existsSync(), isTrue); + + // Clear cache + await downloader.clearCache(); + + // Verify cache is empty + final statsAfter = await downloader.getCacheStats(); + expect(statsAfter['songCount'], equals(0)); + expect(statsAfter['totalSizeBytes'], equals(0)); + + // Verify file was deleted + expect(File(filePath).existsSync(), isFalse); + + print('✓ Cache cleared successfully'); + }, timeout: Timeout(Duration(minutes: 2))); + + test('should handle missing cached files gracefully', () async { + const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; + + // Download a video + final song1 = await downloader.download(testUrl); + final originalPath = song1.filePath; + + // Manually delete the file + await File(originalPath).delete(); + + // Try to download again - should re-download + final song2 = await downloader.download(testUrl); + + // Should have same metadata but new file + expect(song2.title, equals(song1.title)); + expect(song2.duration, equals(song1.duration)); + expect(File(song2.filePath).existsSync(), isTrue); + + print('✓ Re-download after file deletion: ${song2.title}'); + }, timeout: Timeout(Duration(minutes: 3))); + }); + + group('URL Extraction', () { + test('should extract URLs from HTML anchor tags', () async { + const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; + const htmlInput = 'YouTube Video'; + + // Test the regex pattern used in command handler + final aTagRegex = RegExp(r']*>.*?', caseSensitive: false); + final match = aTagRegex.firstMatch(htmlInput); + + expect(match, isNotNull); + expect(match!.group(1), equals(testUrl)); + + print('✓ HTML URL extraction works correctly'); + }); + }); + }, skip: Platform.environment['SKIP_INTEGRATION_TESTS'] == 'true'); +} diff --git a/test/integration_test.dart b/test/integration_test.dart new file mode 100644 index 0000000..099b65b --- /dev/null +++ b/test/integration_test.dart @@ -0,0 +1,69 @@ +import 'dart:io'; + +import 'package:logging/logging.dart'; +import 'package:mumbullet/src/config/config.dart'; +import 'package:mumbullet/src/logging/logger.dart'; +import 'package:mumbullet/src/mumble/connection.dart'; +import 'package:test/test.dart'; + +void main() { + group('Integration tests', () { + late AppConfig config; + + setUp(() { + final configPath = 'test_config.json'; + final configFile = File(configPath); + + if (!configFile.existsSync()) { + configFile.writeAsStringSync(''' +{ + "mumble": { + "server": "localhost", + "port": 64738, + "username": "TestBot", + "password": "testpass", + "channel": "Music" + }, + "bot": { + "command_prefix": "!", + "default_permission_level": 0, + "max_queue_size": 50, + "cache_directory": "./test_cache", + "max_cache_size_gb": 5 + }, + "dashboard": { + "port": 8080, + "admin_username": "admin", + "admin_password": "changeme" + } +} +'''); + } + + config = AppConfig.fromFile(configPath); + + // Set up logging + final logManager = LogManager.getInstance(); + logManager.initialize( + level: Level.INFO, + logToConsole: true, + ); + }); + + tearDown(() { + final testConfigFile = File('test_config.json'); + if (testConfigFile.existsSync()) { + testConfigFile.deleteSync(); + } + }); + + test('Config validation passes', () { + expect(() => config.validate(), returnsNormally); + }); + + test('Mumble connection creation', () { + final connection = MumbleConnection(config.mumble); + expect(connection, isNotNull); + }); + }); +} \ No newline at end of file diff --git a/test/mumbullet_test.dart b/test/mumbullet_test.dart index bb408b9..2707c47 100644 --- a/test/mumbullet_test.dart +++ b/test/mumbullet_test.dart @@ -1,8 +1,37 @@ +import 'package:logging/logging.dart'; import 'package:mumbullet/mumbullet.dart'; import 'package:test/test.dart'; void main() { - test('calculate', () { - expect(calculate(), 42); + group('LogManager', () { + test('should initialize with console logging', () { + final logManager = LogManager.getInstance(); + + // This test just verifies that initialization doesn't throw + expect(() => logManager.initialize( + level: Level.INFO, + logToConsole: true, + ), returnsNormally); + + logManager.close(); + }); + + test('should log messages at different levels', () { + final logManager = LogManager.getInstance(); + logManager.initialize( + level: Level.ALL, + logToConsole: true, + ); + + // This test just verifies that logging doesn't throw + expect(() { + logManager.debug('Debug message'); + logManager.info('Info message'); + logManager.warning('Warning message'); + logManager.error('Error message'); + }, returnsNormally); + + logManager.close(); + }); }); }