It works baby, seems like timing could be improved or something, but it freakin works
This commit is contained in:
+16
-3
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')}';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user