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
+16 -3
View File
@@ -1,3 +1,16 @@
int calculate() {
return 6 * 7;
}
/// Mumble music bot library
library mumbullet;
export 'src/audio/converter.dart';
export 'src/audio/downloader.dart';
export 'src/command/command_handler.dart';
export 'src/command/command_parser.dart';
export 'src/config/config.dart';
export 'src/logging/logger.dart';
export 'src/mumble/audio_streamer.dart';
export 'src/mumble/connection.dart';
export 'src/mumble/message_handler.dart';
export 'src/mumble/models.dart';
export 'src/queue/music_queue.dart';
export 'src/storage/database.dart';
export 'src/storage/permission_manager.dart';
+114
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;
}
}
}
+311
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(),
};
}
}
}
+130
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;
}
}
}
+271
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')}';
}
}
+185
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
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');
}
}
}
+68
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
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
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));
}
}
+962
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
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);
}
}
+364
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();
}
}
+382
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();
}
}
+157
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();
}
}
+41
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)';
}
+246
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();
}
}
+430
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();
}
}
+130
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;
}
}
}