It works baby, seems like timing could be improved or something, but it freakin works

This commit is contained in:
Nate Anderson 2025-06-18 20:59:24 -06:00
parent b68614257d
commit 0745a4eb75
50 changed files with 7679 additions and 20 deletions

2
.gitignore vendored
View File

@ -2,3 +2,5 @@
# Created by `dart pub` # Created by `dart pub`
.dart_tool/ .dart_tool/
.direnv/ .direnv/
cache/
**/.claude/settings.local.json

122
README.md
View File

@ -1,2 +1,120 @@
A sample command-line application with an entrypoint in `bin/`, library code # MumBullet - Mumble Music Bot
in `lib/`, and example unit test in `test/`.
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.

395
ROADMAP.md Normal file
View File

@ -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**:
- `<prefix>play <url>` - Clear queue and immediately play song
- `<prefix>queue <url>` - Add song to queue
- `<prefix>skip` - Skip current song
- `<prefix>list` - Show current queue
- `<prefix>clear` - Clear queue (requires permissions)
- `<prefix>shuffle` - Shuffle downloaded songs and start playing
- `<prefix>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

View File

@ -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<String> arguments) { Future<void> main(List<String> arguments) async {
print('Hello world: ${mumbullet.calculate()}!'); // 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<String, dynamic>;
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<void>();
// 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;
}
} }

15
build.yaml Normal file
View File

@ -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

21
config.json Normal file
View File

@ -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"
}
}

20
dart_test.yaml Normal file
View File

@ -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

39
dev-compose.yml Normal file
View File

@ -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=<strong>Welcome to the MumBullet Development Server</strong><br/>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

30
docker-compose.yml Normal file
View File

@ -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=<strong>Welcome to the MumBullet Test Server</strong><br/>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

View File

@ -21,6 +21,9 @@
buildInputs = with pkgs; [ buildInputs = with pkgs; [
flutter flutter
dart dart
yt-dlp
ffmpeg
libopus
clang clang
cmake cmake
ninja ninja

View File

@ -1,3 +1,16 @@
int calculate() { /// Mumble music bot library
return 6 * 7; 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';

View File

@ -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<String> 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<Uint8List> 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<List<Uint8List>> 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 = <Uint8List>[];
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;
}
}
}

View File

@ -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<Song> 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<ProcessResult> _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<String> _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<int> _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<void> _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<List<Song>> getCachedSongs() async {
return _db.cache.getAllSongs();
}
/// Clear the entire cache
Future<void> 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<Map<String, dynamic>> 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(),
};
}
}
}

View File

@ -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<void> 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;
}
}
}

View File

@ -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 <url>',
requiredPermissionLevel: 2,
requiresArgs: true,
execute: _handlePlayCommand,
));
// Queue command
_commandParser.registerCommand(Command(
name: 'queue',
description: 'Add a song to the queue',
usage: 'queue <url>',
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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _handleClearCommand(CommandContext context) async {
_musicQueue.clear();
await context.reply('Queue cleared.');
}
/// Handle the shuffle command
Future<void> _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 <a> 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 <a> tag
final aTagRegex = RegExp(r'<a\s+href="([^"]+)"[^>]*>.*?</a>', 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')}';
}
}

View File

@ -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<void> 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<void> 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<String, Command> _commands = {};
/// Function to get permission level
final Future<int> 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<CommandResult> _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<Command> getCommands() {
return _commands.values.toList();
}
/// Get commands filtered by permission level
List<Command> getCommandsForPermissionLevel(int permissionLevel) {
return _commands.values
.where((cmd) => cmd.requiredPermissionLevel <= permissionLevel)
.toList();
}
}

119
lib/src/config/config.dart Normal file
View File

@ -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<String, dynamic> json) =>
_$MumbleConfigFromJson(json);
Map<String, dynamic> 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<String, dynamic> json) =>
_$BotConfigFromJson(json);
Map<String, dynamic> 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<String, dynamic> json) =>
_$DashboardConfigFromJson(json);
Map<String, dynamic> 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<String, dynamic> json) =>
_$AppConfigFromJson(json);
Map<String, dynamic> 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<String, dynamic>;
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');
}
}
}

View File

@ -0,0 +1,68 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'config.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
MumbleConfig _$MumbleConfigFromJson(Map<String, dynamic> 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<String, dynamic> _$MumbleConfigToJson(MumbleConfig instance) =>
<String, dynamic>{
'server': instance.server,
'port': instance.port,
'username': instance.username,
'password': instance.password,
'channel': instance.channel,
};
BotConfig _$BotConfigFromJson(Map<String, dynamic> 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<String, dynamic> _$BotConfigToJson(BotConfig instance) => <String, dynamic>{
'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<String, dynamic> json) =>
DashboardConfig(
port: (json['port'] as num).toInt(),
adminUsername: json['admin_username'] as String,
adminPassword: json['admin_password'] as String,
);
Map<String, dynamic> _$DashboardConfigToJson(DashboardConfig instance) =>
<String, dynamic>{
'port': instance.port,
'admin_username': instance.adminUsername,
'admin_password': instance.adminPassword,
};
AppConfig _$AppConfigFromJson(Map<String, dynamic> json) => AppConfig(
mumble: MumbleConfig.fromJson(json['mumble'] as Map<String, dynamic>),
bot: BotConfig.fromJson(json['bot'] as Map<String, dynamic>),
dashboard: DashboardConfig.fromJson(
json['dashboard'] as Map<String, dynamic>,
),
);
Map<String, dynamic> _$AppConfigToJson(AppConfig instance) => <String, dynamic>{
'mumble': instance.mumble.toJson(),
'bot': instance.bot.toJson(),
'dashboard': instance.dashboard.toJson(),
};

210
lib/src/dashboard/api.dart Normal file
View File

@ -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/<id>/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<Response> _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<Response> _getCacheStats(Request request) async {
final stats = await _downloader.getCacheStats();
return Response.ok(
json.encode(stats),
headers: {'content-type': 'application/json'},
);
}
/// Clear the cache
Future<Response> _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<Response> _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<Response> _updateUserPermission(Request request) async {
final username = request.params['id'] as String;
final jsonString = await request.readAsString();
final Map<String, dynamic> 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<Response> _createUser(Request request) async {
final jsonString = await request.readAsString();
final Map<String, dynamic> 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';
}
}
}

167
lib/src/dashboard/auth.dart Normal file
View File

@ -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<String, DateTime> _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<Response> handleLogin(Request request) async {
final contentType = request.headers['content-type'];
if (contentType == 'application/json') {
// API login
final jsonString = await request.readAsString();
final Map<String, dynamic> 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<int>.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));
}
}

View File

@ -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<void> 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('/<ignored|.*>', (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<void> 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'
? '<div class="error">Invalid username or password</div>'
: '';
final html = _getDefaultLoginHtml().replaceAll('<!--ERROR-->', 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 '''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MumBullet Dashboard</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header>
<h1>MumBullet Dashboard</h1>
<nav>
<button id="queueTab" class="tab-button active">Queue</button>
<button id="usersTab" class="tab-button">Users</button>
<button id="cacheTab" class="tab-button">Cache</button>
<button id="logoutButton">Logout</button>
</nav>
</header>
<main>
<section id="queueSection" class="tab-content active">
<h2>Music Queue</h2>
<div class="card">
<div class="card-header">
<h3>Now Playing</h3>
</div>
<div class="card-content" id="nowPlaying">
<p>Nothing playing</p>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>Queue</h3>
<button id="clearQueueButton" class="danger-button">Clear Queue</button>
</div>
<div class="card-content">
<ul id="queueList" class="queue-list">
<li class="empty-message">Queue is empty</li>
</ul>
</div>
</div>
</section>
<section id="usersSection" class="tab-content">
<h2>User Management</h2>
<div class="card">
<div class="card-header">
<h3>Users</h3>
<button id="addUserButton">Add User</button>
</div>
<div class="card-content">
<table id="usersTable" class="data-table">
<thead>
<tr>
<th>Username</th>
<th>Permission Level</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr class="empty-message">
<td colspan="3">No users found</td>
</tr>
</tbody>
</table>
</div>
</div>
<div id="addUserModal" class="modal">
<div class="modal-content">
<h3>Add User</h3>
<form id="addUserForm">
<div class="form-group">
<label for="newUsername">Username</label>
<input type="text" id="newUsername" name="username" required>
</div>
<div class="form-group">
<label for="newPermissionLevel">Permission Level</label>
<select id="newPermissionLevel" name="permissionLevel" required>
<option value="0">None</option>
<option value="1">View Only</option>
<option value="2">Read/Write</option>
<option value="3">Admin</option>
</select>
</div>
<div class="form-actions">
<button type="button" id="cancelAddUser">Cancel</button>
<button type="submit">Add User</button>
</div>
</form>
</div>
</div>
</section>
<section id="cacheSection" class="tab-content">
<h2>Cache Management</h2>
<div class="card">
<div class="card-header">
<h3>Cache Statistics</h3>
<button id="clearCacheButton" class="danger-button">Clear Cache</button>
</div>
<div class="card-content">
<div id="cacheStats" class="stats-grid">
<div class="stat-item">
<span class="stat-label">Songs</span>
<span class="stat-value" id="cacheSongCount">0</span>
</div>
<div class="stat-item">
<span class="stat-label">Size</span>
<span class="stat-value" id="cacheSize">0 MB</span>
</div>
<div class="stat-item">
<span class="stat-label">Max Size</span>
<span class="stat-value" id="cacheMaxSize">0 MB</span>
</div>
<div class="stat-item">
<span class="stat-label">Usage</span>
<div class="progress-bar">
<div id="cacheUsage" class="progress-value" style="width: 0%"></div>
</div>
</div>
</div>
</div>
</div>
</section>
</main>
<script src="/script.js"></script>
</body>
</html>''';
}
/// Get the default login.html content
String _getDefaultLoginHtml() {
return '''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - MumBullet Dashboard</title>
<link rel="stylesheet" href="/style.css">
</head>
<body class="login-page">
<div class="login-container">
<h1>MumBullet Dashboard</h1>
<div class="card">
<div class="card-header">
<h2>Login</h2>
</div>
<div class="card-content">
<!--ERROR-->
<form action="/api/login" method="post">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-actions">
<button type="submit">Login</button>
</div>
</form>
</div>
</div>
</div>
</body>
</html>''';
}
/// 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 = `
<div class="song-info">
<h4>${responseData.current_song.title}</h4>
<p>${formatDuration(responseData.current_song.duration)}</p>
</div>
<div class="song-status">
<span class="badge">${responseData.state}</span>
</div>
`;
} else {
nowPlaying.innerHTML = '<p>Nothing playing</p>';
}
// Update queue
if (responseData.queue.length > 0) {
queueList.innerHTML = responseData.queue.map((songItem, idx) => {
if (songItem.is_current) {
return `
<li class="now-playing">
<div class="song-info">
<strong>${songItem.title}</strong>
<span>${formatDuration(songItem.duration)}</span>
</div>
<div class="song-status">
<span class="badge">Now Playing</span>
</div>
</li>
`;
} else {
return `
<li>
<div class="song-info">
<strong>${idx + 1}. ${songItem.title}</strong>
<span>${formatDuration(songItem.duration)}</span>
</div>
</li>
`;
}
}).join('');
} else {
queueList.innerHTML = '<li class="empty-message">Queue is empty</li>';
}
} 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 => `
<tr>
<td>${userItem.username}</td>
<td>
<select class="permission-select" data-username="${userItem.username}">
<option value="0" ${userItem.permission_level == 0 ? 'selected' : ''}>None</option>
<option value="1" ${userItem.permission_level == 1 ? 'selected' : ''}>View Only</option>
<option value="2" ${userItem.permission_level == 2 ? 'selected' : ''}>Read/Write</option>
<option value="3" ${userItem.permission_level == 3 ? 'selected' : ''}>Admin</option>
</select>
</td>
<td>
<button class="save-permission" data-username="${userItem.username}">Save</button>
</td>
</tr>
`).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 = '<tr class="empty-message"><td colspan="3">No users found</td></tr>';
}
} 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;
}
}

113
lib/src/logging/logger.dart Normal file
View File

@ -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);
}
}

View File

@ -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<void>? _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<Uint8List> _frameQueue = [];
DateTime? _nextFrameTime;
static const Duration _frameDuration = Duration(milliseconds: 20);
/// Start streaming audio from a file
Future<void> 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<void>();
_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<void> _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<File> _resampleAudioFile(File inputFile, Map<String, int> 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<String, int> _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<void> _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<void> _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<void> 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();
}
}

View File

@ -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<BanEntry> 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<int, String> idToName) {}
@override
void onUserListReceived(List<RegisteredUser> 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<bool>.broadcast();
/// Stream controller for user join/leave events
final _userJoinedController = StreamController<models.MumbleUser>.broadcast();
final _userLeftController = StreamController<models.MumbleUser>.broadcast();
/// Stream controller for text messages
final _textMessageController = StreamController<Map<String, dynamic>>.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<bool> get connectionState => _connectionStateController.stream;
/// Stream of user joined events
Stream<models.MumbleUser> get userJoined => _userJoinedController.stream;
/// Stream of user left events
Stream<models.MumbleUser> get userLeft => _userLeftController.stream;
/// Stream of text messages - returns {message: String, sender: MumbleUser}
Stream<Map<String, dynamic>> 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<void> 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<void> 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<void> 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<void> 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<void> 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();
}
}

View File

@ -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<Map<String, dynamic>>.broadcast();
final List<StreamSubscription> _subscriptions = [];
/// Create a new message handler
MumbleMessageHandler(this._connection, this._config)
: _logManager = LogManager.getInstance() {
_setupHandlers();
}
/// Stream of incoming commands
Stream<Map<String, dynamic>> 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<void> 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<void> 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<void> 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();
}
}

View File

@ -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)';
}

View File

@ -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<Song> _queue = Queue<Song>();
Song? _currentSong;
QueueState _state = QueueState.idle;
final _eventController = StreamController<Map<String, dynamic>>.broadcast();
Completer<void>? _playbackCompleter;
/// Create a new music queue
MusicQueue(
this._config,
this._audioStreamer,
this._audioConverter,
) : _logManager = LogManager.getInstance();
/// Stream of queue events
Stream<Map<String, dynamic>> 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<Song> getQueue() {
return List<Song>.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<void> _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<void>();
// 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<String, dynamic> 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();
}
}

View File

@ -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<User?> 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<User?> 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<User> 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<List<User>> 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<int?> 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<void> 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<Song> 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<Song?> 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<Song?> 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<void> 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<void> 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<List<Song>> 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<void> 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();
}
}

View File

@ -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<int> 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<bool> 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<Map<String, int>> getAllUserPermissions() async {
try {
final result = <String, int>{};
// 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<bool> 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;
}
}
}

View File

@ -18,7 +18,7 @@ packages:
source: hosted source: hosted
version: "7.4.5" version: "7.4.5"
args: args:
dependency: transitive dependency: "direct main"
description: description:
name: args name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
@ -41,6 +41,78 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" 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: cli_config:
dependency: transitive dependency: transitive
description: description:
@ -49,6 +121,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.0" 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: collection:
dependency: transitive dependency: transitive
description: description:
@ -74,13 +154,37 @@ packages:
source: hosted source: hosted
version: "1.14.1" version: "1.14.1"
crypto: crypto:
dependency: transitive dependency: "direct main"
description: description:
name: crypto name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.6" 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: file:
dependency: transitive dependency: transitive
description: description:
@ -89,6 +193,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" 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: frontend_server_client:
dependency: transitive dependency: transitive
description: description:
@ -105,6 +217,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.3" 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: http_multi_server:
dependency: transitive dependency: transitive
description: description:
@ -137,6 +273,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.2" 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: lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -146,7 +298,7 @@ packages:
source: hosted source: hosted
version: "5.1.1" version: "5.1.1"
logging: logging:
dependency: transitive dependency: "direct main"
description: description:
name: logging name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
@ -177,6 +329,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" 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: node_preamble:
dependency: transitive dependency: transitive
description: description:
@ -185,6 +345,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.2" 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: package_config:
dependency: transitive dependency: transitive
description: description:
@ -194,13 +362,21 @@ packages:
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
path: path:
dependency: transitive dependency: "direct main"
description: description:
name: path name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
pointycastle:
dependency: transitive
description:
name: pointycastle
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
url: "https://pub.dev"
source: hosted
version: "3.9.1"
pool: pool:
dependency: transitive dependency: transitive
description: description:
@ -209,6 +385,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.1" version: "1.5.1"
protobuf:
dependency: transitive
description:
name: protobuf
sha256: "01dd9bd0fa02548bf2ceee13545d4a0ec6046459d847b6b061d8a27237108a08"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
@ -217,8 +401,16 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
shelf: pubspec_parse:
dependency: transitive dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
shelf:
dependency: "direct main"
description: description:
name: shelf name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
@ -233,8 +425,16 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.2" 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: shelf_static:
dependency: transitive dependency: "direct main"
description: description:
name: shelf_static name: shelf_static
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
@ -249,6 +449,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" 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: source_map_stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -273,6 +489,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.1" 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: stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -289,6 +513,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" 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: string_scanner:
dependency: transitive dependency: transitive
description: description:
@ -329,6 +561,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.11" version: "0.6.11"
timing:
dependency: transitive
description:
name: timing
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@ -394,4 +634,4 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.7.3 <4.0.0" dart: ">=3.8.0 <4.0.0"

View File

@ -1,15 +1,28 @@
name: mumbullet 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 version: 1.0.0
# repository: https://github.com/my_org/my_repo # repository: https://github.com/my_org/my_repo
environment: environment:
sdk: ^3.7.3 sdk: ^3.7.3
# Add regular dependencies here.
dependencies: 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: dev_dependencies:
lints: ^5.0.0 lints: ^5.0.0
test: ^1.24.0 test: ^1.24.0
json_serializable: ^6.6.0
build_runner: ^2.4.0
mockito: ^5.4.2

113
scripts/dev-server.sh Normal file
View File

@ -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

View File

@ -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"

View File

@ -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<Exception>());
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');
}

View File

@ -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 = <Song>[];
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');
}

View File

@ -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 = '<a href="https://www.youtube.com/watch?v=dpx6L15oA4k">https://www.youtube.com/watch?v=dpx6L15oA4k</a>';
final aTagRegex = RegExp(r'<a\s+href="([^"]+)"[^>]*>.*?</a>', 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'<a\s+href="([^"]+)"[^>]*>.*?</a>', caseSensitive: false);
final match = aTagRegex.firstMatch(input);
expect(match, isNull);
});
test('should handle various HTML anchor tag formats', () {
final testCases = [
'<a href="https://example.com">Link</a>',
'<a href="https://example.com" target="_blank">Link</a>',
'<A HREF="https://example.com">Link</A>',
'<a href="https://example.com" class="link">Link Text</a>',
];
final aTagRegex = RegExp(r'<a\s+href="([^"]+)"[^>]*>.*?</a>', 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'<a\s+href="([^"]+)"[^>]*>.*?</a>', caseSensitive: false);
for (final testCase in testCases) {
final match = aTagRegex.firstMatch(testCase.trim());
expect(match, isNull, reason: 'Should not match empty/whitespace: "$testCase"');
}
});
});
}

74
test/config_test.dart Normal file
View File

@ -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<FileSystemException>()));
});
test('should validate configuration', () {
final config = AppConfig.fromFile(tempConfigFile.path);
// Valid configuration should not throw
expect(() => config.validate(), returnsNormally);
});
});
}

71
test/fixtures/mumble-server.conf vendored Normal file
View File

@ -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=

69
test/fixtures/murmur.ini vendored Normal file
View File

@ -0,0 +1,69 @@
# Mumble server configuration file for testing
# Basic server settings
database=/var/lib/mumble-server/mumble-server.sqlite
welcometext="<strong>Welcome to the MumBullet Test Server</strong><br/>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

21
test/fixtures/test_config.json vendored Normal file
View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -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"
}
}

103
test/integration/README.md Normal file
View File

@ -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.

View File

@ -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<String> replies = [];
MockCommandContext({
required this.args,
this.permissionLevel = 2,
});
Future<void> 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 = '<a href="$testUrl">YouTube Video</a>';
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',
'<a href="https://www.youtube.com/watch?v=jNQXAC9IVRw">Video Link</a>',
];
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');
}

View File

@ -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<bool>();
// 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<bool>();
// 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<bool>();
final reconnectionCompleter = Completer<bool>();
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);
});
}

View File

@ -0,0 +1,7 @@
tags:
docker:
# Integration tests that require Docker to be running
on_os:
- linux
- mac-os
timeout: 60s

View File

@ -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

View File

@ -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 = <Song>[];
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<Exception>());
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<Exception>());
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 = '<a href="$testUrl">YouTube Video</a>';
// Test the regex pattern used in command handler
final aTagRegex = RegExp(r'<a\s+href="([^"]+)"[^>]*>.*?</a>', 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');
}

View File

@ -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);
});
});
}

View File

@ -1,8 +1,37 @@
import 'package:logging/logging.dart';
import 'package:mumbullet/mumbullet.dart'; import 'package:mumbullet/mumbullet.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
void main() { void main() {
test('calculate', () { group('LogManager', () {
expect(calculate(), 42); 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();
});
}); });
} }