Improved dashboard and music queue

This commit is contained in:
Nate Anderson 2025-06-21 11:47:06 -06:00
parent 0745a4eb75
commit 71f535be27
16 changed files with 1386 additions and 664 deletions

1
.gitignore vendored
View File

@ -4,3 +4,4 @@
.direnv/ .direnv/
cache/ cache/
**/.claude/settings.local.json **/.claude/settings.local.json
*.log

View File

@ -3,35 +3,28 @@ import 'dart:async';
import 'package:args/args.dart'; import 'package:args/args.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:mumbullet/mumbullet.dart'; import 'package:mumbullet/mumbullet.dart';
import 'package:mumbullet/src/dashboard/server.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
Future<void> main(List<String> arguments) async { Future<void> main(List<String> arguments) async {
// Parse command line arguments // Parse command line arguments
final parser = ArgParser() final parser =
..addOption('config', ArgParser()
abbr: 'c', ..addOption('config', abbr: 'c', defaultsTo: 'config.json', help: 'Path to configuration file')
defaultsTo: 'config.json', ..addOption(
help: 'Path to configuration file') 'log-level',
..addOption('log-level', abbr: 'l',
abbr: 'l', defaultsTo: 'info',
defaultsTo: 'info', allowed: ['debug', 'info', 'warning', 'error'],
allowed: ['debug', 'info', 'warning', 'error'], help: 'Log level (debug, info, warning, error)',
help: 'Log level (debug, info, warning, error)') )
..addFlag('console-log', ..addFlag('console-log', abbr: 'o', defaultsTo: true, help: 'Log to console')
abbr: 'o', ..addOption('log-file', abbr: 'f', help: 'Path to log file')
defaultsTo: true, ..addFlag('help', abbr: 'h', negatable: false, help: 'Display this help message');
help: 'Log to console')
..addOption('log-file',
abbr: 'f',
help: 'Path to log file')
..addFlag('help',
abbr: 'h',
negatable: false,
help: 'Display this help message');
try { try {
final results = parser.parse(arguments); final results = parser.parse(arguments);
if (results['help'] == true) { if (results['help'] == true) {
print('Mumble Music Bot - A Dart-based music bot for Mumble servers'); print('Mumble Music Bot - A Dart-based music bot for Mumble servers');
print(''); print('');
@ -42,25 +35,25 @@ Future<void> main(List<String> arguments) async {
print(parser.usage); print(parser.usage);
exit(0); exit(0);
} }
// Set up logging // Set up logging
final logLevelString = results['log-level'] as String; final logLevelString = results['log-level'] as String;
final Level logLevel = _parseLogLevel(logLevelString); final Level logLevel = _parseLogLevel(logLevelString);
final logManager = LogManager.getInstance(); final logManager = LogManager.getInstance();
logManager.initialize( logManager.initialize(
level: logLevel, level: logLevel,
logToConsole: results['console-log'] as bool, logToConsole: results['console-log'] as bool,
logFilePath: results['log-file'] as String?, logFilePath: results['log-file'] as String?,
); );
// Load configuration // Load configuration
final configPath = results['config'] as String; final configPath = results['config'] as String;
final logger = logManager.logger; final logger = logManager.logger;
logger.info('Starting Mumble Music Bot'); logger.info('Starting Mumble Music Bot');
logger.info('Loading configuration from $configPath'); logger.info('Loading configuration from $configPath');
final config = AppConfig.fromFile(configPath); final config = AppConfig.fromFile(configPath);
try { try {
config.validate(); config.validate();
@ -69,46 +62,51 @@ Future<void> main(List<String> arguments) async {
logger.severe('Configuration validation error: $e'); logger.severe('Configuration validation error: $e');
exit(1); exit(1);
} }
// Initialize database // Initialize database
final dbPath = path.join(config.bot.cacheDirectory, 'mumbullet.db'); final dbPath = path.join(config.bot.cacheDirectory, 'mumbullet.db');
logger.info('Initializing database: $dbPath'); logger.info('Initializing database: $dbPath');
final database = DatabaseManager(dbPath); final database = DatabaseManager(dbPath);
// Initialize permission manager // Initialize permission manager
final permissionManager = PermissionManager(database, config.bot); final permissionManager = PermissionManager(database, config.bot);
// Create Mumble connection // Create Mumble connection
logger.info('Connecting to Mumble server ${config.mumble.server}:${config.mumble.port}'); logger.info('Connecting to Mumble server ${config.mumble.server}:${config.mumble.port}');
final mumbleConnection = MumbleConnection(config.mumble); final mumbleConnection = MumbleConnection(config.mumble);
// Create message handler // Create message handler
final messageHandler = MumbleMessageHandler(mumbleConnection, config.bot); final messageHandler = MumbleMessageHandler(mumbleConnection, config.bot);
// Initialize audio services // Initialize audio services
logger.info('Initializing audio services'); logger.info('Initializing audio services');
final audioConverter = AudioConverter(); final audioConverter = AudioConverter();
final youtubeDownloader = YoutubeDownloader(config.bot, database); final youtubeDownloader = YoutubeDownloader(config.bot, database);
final audioStreamer = MumbleAudioStreamer(mumbleConnection); final audioStreamer = MumbleAudioStreamer(mumbleConnection);
// Create music queue // Create music queue
final musicQueue = MusicQueue(config.bot, audioStreamer, audioConverter); final musicQueue = MusicQueue(config.bot, audioStreamer, audioConverter);
// Create command parser with permission callback // Create command parser with permission callback
final commandParser = CommandParser( final commandParser = CommandParser(
messageHandler, messageHandler,
config.bot, config.bot,
(user) => permissionManager.getPermissionLevel(user), (user) => permissionManager.getPermissionLevel(user),
); );
// Create command handler // Create command handler with connection for private messaging
final commandHandler = CommandHandler( final commandHandler = CommandHandler(
commandParser, commandParser,
musicQueue, musicQueue,
youtubeDownloader, youtubeDownloader,
config.bot, config.bot,
mumbleConnection,
); );
final dashboardServer = DashboardServer(config.dashboard, musicQueue, youtubeDownloader, permissionManager);
dashboardServer.start();
// Set up event listeners // Set up event listeners
mumbleConnection.connectionState.listen((isConnected) { mumbleConnection.connectionState.listen((isConnected) {
if (isConnected) { if (isConnected) {
@ -117,22 +115,22 @@ Future<void> main(List<String> arguments) async {
logger.info('Disconnected from Mumble server'); logger.info('Disconnected from Mumble server');
} }
}); });
mumbleConnection.userJoined.listen((user) { mumbleConnection.userJoined.listen((user) {
logger.info('User joined: ${user.name}'); logger.info('User joined: ${user.name}');
}); });
mumbleConnection.userLeft.listen((user) { mumbleConnection.userLeft.listen((user) {
logger.info('User left: ${user.name}'); logger.info('User left: ${user.name}');
}); });
// Listen to music queue events // Listen to music queue events
musicQueue.events.listen((event) { musicQueue.events.listen((event) {
final eventType = event['event'] as String; final eventType = event['event'] as String;
final data = event['data'] as Map<String, dynamic>; final data = event['data'] as Map<String, dynamic>;
logger.info('Music queue event: $eventType'); logger.info('Music queue event: $eventType');
// Log important events // Log important events
switch (eventType) { switch (eventType) {
case 'songStarted': case 'songStarted':
@ -148,37 +146,39 @@ Future<void> main(List<String> arguments) async {
break; break;
} }
}); });
// Connect to Mumble server // Connect to Mumble server
try { try {
await mumbleConnection.connect(); await mumbleConnection.connect();
// Send a welcome message to the channel // Send a welcome message to the channel
if (mumbleConnection.isConnected) { if (mumbleConnection.isConnected) {
await mumbleConnection.sendChannelMessage('MumBullet Music Bot is online! Type ${config.bot.commandPrefix}help for a list of commands.'); await mumbleConnection.sendChannelMessage(
'MumBullet Music Bot is online! Type ${config.bot.commandPrefix}help for a list of commands.',
);
logger.info('Sent welcome message to channel'); logger.info('Sent welcome message to channel');
} }
} catch (e) { } catch (e) {
logger.warning('Failed to connect to Mumble server: $e'); logger.warning('Failed to connect to Mumble server: $e');
logger.info('The bot will automatically attempt to reconnect...'); logger.info('The bot will automatically attempt to reconnect...');
} }
logger.info('MumBullet is now running with the following features:'); logger.info('MumBullet is now running with the following features:');
logger.info('- Mumble server connection'); logger.info('- Mumble server connection');
logger.info('- Command processing with permissions'); logger.info('- Command processing with permissions');
logger.info('- YouTube audio downloading and streaming'); logger.info('- YouTube audio downloading and streaming');
logger.info('- Queue management'); logger.info('- Queue management');
logger.info('- Admin dashboard'); logger.info('- Admin dashboard');
logger.info('Press Ctrl+C to exit'); logger.info('Press Ctrl+C to exit');
// Wait for shutdown // Wait for shutdown
final completer = Completer<void>(); final completer = Completer<void>();
// Set up signal handlers // Set up signal handlers
ProcessSignal.sigint.watch().listen((_) async { ProcessSignal.sigint.watch().listen((_) async {
logger.info('Shutting down Mumble Music Bot'); logger.info('Shutting down Mumble Music Bot');
// Stop music playback // Stop music playback
try { try {
await audioStreamer.stopStreaming(); await audioStreamer.stopStreaming();
@ -186,7 +186,7 @@ Future<void> main(List<String> arguments) async {
} catch (e) { } catch (e) {
logger.warning('Error stopping audio services: $e'); logger.warning('Error stopping audio services: $e');
} }
// Disconnect from Mumble server // Disconnect from Mumble server
if (mumbleConnection.isConnected) { if (mumbleConnection.isConnected) {
try { try {
@ -196,18 +196,19 @@ Future<void> main(List<String> arguments) async {
logger.warning('Error during shutdown: $e'); logger.warning('Error during shutdown: $e');
} }
} }
// Dispose resources // Dispose resources
mumbleConnection.dispose(); mumbleConnection.dispose();
messageHandler.dispose(); messageHandler.dispose();
database.close(); database.close();
await dashboardServer.stop();
completer.complete(); completer.complete();
}); });
ProcessSignal.sigterm.watch().listen((_) async { ProcessSignal.sigterm.watch().listen((_) async {
logger.info('Shutting down Mumble Music Bot'); logger.info('Shutting down Mumble Music Bot');
// Stop music playback // Stop music playback
try { try {
await audioStreamer.stopStreaming(); await audioStreamer.stopStreaming();
@ -215,7 +216,7 @@ Future<void> main(List<String> arguments) async {
} catch (e) { } catch (e) {
logger.warning('Error stopping audio services: $e'); logger.warning('Error stopping audio services: $e');
} }
// Disconnect from Mumble server // Disconnect from Mumble server
if (mumbleConnection.isConnected) { if (mumbleConnection.isConnected) {
try { try {
@ -225,21 +226,21 @@ Future<void> main(List<String> arguments) async {
logger.warning('Error during shutdown: $e'); logger.warning('Error during shutdown: $e');
} }
} }
// Dispose resources // Dispose resources
mumbleConnection.dispose(); mumbleConnection.dispose();
messageHandler.dispose(); messageHandler.dispose();
database.close(); database.close();
await dashboardServer.stop();
completer.complete(); completer.complete();
}); });
await completer.future; await completer.future;
// Close logger // Close logger
logManager.close(); logManager.close();
exit(0); exit(0);
} catch (e, stackTrace) { } catch (e, stackTrace) {
print('Error: $e'); print('Error: $e');
print('Stack trace: $stackTrace'); print('Stack trace: $stackTrace');

View File

@ -9,49 +9,20 @@
... ...
}: }:
flake-utils.lib.eachDefaultSystem (system: let flake-utils.lib.eachDefaultSystem (system: let
lib = nixpkgs.lib;
pkgs = import nixpkgs { pkgs = import nixpkgs {
inherit system; inherit system;
config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [
"android-studio-stable"
];
}; };
in { in {
devShell = pkgs.mkShell { devShell = pkgs.mkShell {
buildInputs = with pkgs; [ buildInputs = with pkgs; [
flutter
dart dart
yt-dlp yt-dlp
ffmpeg ffmpeg
libopus libopus
clang
cmake
ninja
pkg-config pkg-config
gtk3
pcre
libepoxy
glib.dev
sysprof
# For drift / sqlite3 dart
sqlite sqlite
# For xp overlay
gtk-layer-shell
# Dev deps
libuuid # for mount.pc libuuid # for mount.pc
xorg.libXdmcp.dev xorg.libXdmcp.dev
# python310Packages.libselinux.dev # for libselinux.pc
libsepol.dev
libthai.dev
libdatrie.dev
libxkbcommon.dev
dbus.dev
at-spi2-core.dev
xorg.libXtst.out
pcre2.dev
jdk11
android-studio
android-tools
]; ];
LD_LIBRARY_PATH = "${pkgs.sqlite.out}/lib"; LD_LIBRARY_PATH = "${pkgs.sqlite.out}/lib";
shellHook = '' shellHook = ''

View File

@ -5,6 +5,7 @@ import 'package:mumbullet/src/audio/downloader.dart';
import 'package:mumbullet/src/command/command_parser.dart'; import 'package:mumbullet/src/command/command_parser.dart';
import 'package:mumbullet/src/config/config.dart'; import 'package:mumbullet/src/config/config.dart';
import 'package:mumbullet/src/logging/logger.dart'; import 'package:mumbullet/src/logging/logger.dart';
import 'package:mumbullet/src/mumble/connection.dart';
import 'package:mumbullet/src/mumble/message_handler.dart'; import 'package:mumbullet/src/mumble/message_handler.dart';
import 'package:mumbullet/src/queue/music_queue.dart'; import 'package:mumbullet/src/queue/music_queue.dart';
@ -15,249 +16,272 @@ class CommandHandler {
final YoutubeDownloader _downloader; final YoutubeDownloader _downloader;
final BotConfig _config; final BotConfig _config;
final LogManager _logManager; final LogManager _logManager;
final MumbleConnection _connection;
/// Create a new command handler /// Create a new command handler
CommandHandler( CommandHandler(this._commandParser, this._musicQueue, this._downloader, this._config, this._connection)
this._commandParser, : _logManager = LogManager.getInstance() {
this._musicQueue,
this._downloader,
this._config,
) : _logManager = LogManager.getInstance() {
_registerCommands(); _registerCommands();
} }
/// Register all available commands /// Register all available commands
void _registerCommands() { void _registerCommands() {
// Help command // Help command
_commandParser.registerCommand(Command( _commandParser.registerCommand(
name: 'help', Command(
description: 'Show available commands', name: 'help',
usage: 'help', description: 'Show available commands',
requiredPermissionLevel: 0, usage: 'help',
execute: _handleHelpCommand, requiredPermissionLevel: 0,
)); execute: _handleHelpCommand,
),
);
// Play command // Play command
_commandParser.registerCommand(Command( _commandParser.registerCommand(
name: 'play', Command(
description: 'Clear queue and play a song immediately', name: 'play',
usage: 'play <url>', description: 'Clear queue and play a song immediately',
requiredPermissionLevel: 2, usage: 'play &lt;url&gt;',
requiresArgs: true, requiredPermissionLevel: 2,
execute: _handlePlayCommand, requiresArgs: true,
)); execute: _handlePlayCommand,
),
);
// Queue command // Queue command
_commandParser.registerCommand(Command( _commandParser.registerCommand(
name: 'queue', Command(
description: 'Add a song to the queue', name: 'queue',
usage: 'queue <url>', description: 'Add a song to the queue',
requiredPermissionLevel: 1, usage: 'queue &lt;url&gt;',
requiresArgs: true, requiredPermissionLevel: 1,
execute: _handleQueueCommand, requiresArgs: true,
)); execute: _handleQueueCommand,
),
);
// Skip command // Skip command
_commandParser.registerCommand(Command( _commandParser.registerCommand(
name: 'skip', Command(
description: 'Skip the current song', name: 'skip',
usage: 'skip', description: 'Skip the current song',
requiredPermissionLevel: 2, usage: 'skip',
execute: _handleSkipCommand, requiredPermissionLevel: 2,
)); execute: _handleSkipCommand,
),
);
// List command // List command
_commandParser.registerCommand(Command( _commandParser.registerCommand(
name: 'list', Command(
description: 'Show the current queue', name: 'list',
usage: 'list', description: 'Show the current queue',
requiredPermissionLevel: 0, usage: 'list',
execute: _handleListCommand, requiredPermissionLevel: 0,
)); execute: _handleListCommand,
),
);
// Clear command // Clear command
_commandParser.registerCommand(Command( _commandParser.registerCommand(
name: 'clear', Command(
description: 'Clear the queue', name: 'clear',
usage: 'clear', description: 'Clear the queue',
requiredPermissionLevel: 2, usage: 'clear',
execute: _handleClearCommand, requiredPermissionLevel: 2,
)); execute: _handleClearCommand,
),
);
// Shuffle command // Shuffle command
_commandParser.registerCommand(Command( _commandParser.registerCommand(
name: 'shuffle', Command(
description: 'Shuffle the downloaded songs and start playing', name: 'shuffle',
usage: 'shuffle', description: 'Shuffle the downloaded songs and start playing',
requiredPermissionLevel: 2, usage: 'shuffle',
execute: _handleShuffleCommand, requiredPermissionLevel: 2,
)); execute: _handleShuffleCommand,
),
);
} }
/// Handle the help command /// Handle the help command
Future<void> _handleHelpCommand(CommandContext context) async { Future<void> _handleHelpCommand(CommandContext context) async {
final permissionLevel = context.permissionLevel; final permissionLevel = context.permissionLevel;
final commands = _commandParser.getCommandsForPermissionLevel(permissionLevel); final commands = _commandParser.getCommandsForPermissionLevel(permissionLevel);
final helpText = StringBuffer(); final helpText = StringBuffer();
helpText.writeln('Available commands:'); helpText.writeln('Available commands:<br/>');
for (final command in commands) { for (final command in commands) {
helpText.writeln('${_config.commandPrefix}${command.usage} - ${command.description}'); helpText.writeln('${_config.commandPrefix}${command.usage} - ${command.description}<br/>');
}
// Always send help command response as a private message
// Retrieve the MumbleMessageHandler to send a private message
final sender = context.sender;
try {
// Get message handler and send private message
await context.reply(helpText.toString());
} catch (e) {
// If private message fails, fall back to the regular reply method
_logManager.warning('Failed to send private help message, using replyAll', e);
await context.replyAll(helpText.toString());
} }
await context.reply(helpText.toString());
} }
/// Handle the play command /// Handle the play command
Future<void> _handlePlayCommand(CommandContext context) async { Future<void> _handlePlayCommand(CommandContext context) async {
final url = _extractUrlFromHtml(context.args); final url = _extractUrlFromHtml(context.args);
_logManager.info('Downloading audio from URL: $url'); _logManager.info('Downloading audio from URL: $url');
await context.reply('Downloading audio from: $url'); await context.reply('Downloading audio from: $url');
try { try {
// Clear the queue // Clear the queue
_musicQueue.clear(); _musicQueue.clear();
// Download and enqueue // Download and enqueue
final song = await _downloader.download(url); final song = await _downloader.download(url);
// Add to queue and play immediately // Add to queue and play immediately
_musicQueue.enqueue(song, true); _musicQueue.enqueue(song, true);
await context.reply('Now playing: ${song.title}'); await context.replyAll('Now playing: ${song.title}');
} catch (e) { } catch (e) {
_logManager.error('Failed to play URL: $url', e); _logManager.error('Failed to play URL: $url', e);
await context.reply('Failed to download or play the audio: ${e.toString()}'); await context.reply('Failed to download or play the audio: ${e.toString()}');
} }
} }
/// Handle the queue command /// Handle the queue command
Future<void> _handleQueueCommand(CommandContext context) async { Future<void> _handleQueueCommand(CommandContext context) async {
final url = _extractUrlFromHtml(context.args); final url = _extractUrlFromHtml(context.args);
_logManager.info('Downloading audio from URL: $url'); _logManager.info('Downloading audio from URL: $url');
await context.reply('Adding to queue: $url'); await context.reply('Adding to queue: $url');
try { try {
// Download and enqueue // Download and enqueue
final song = await _downloader.download(url); final song = await _downloader.download(url);
// Add to queue // Add to queue
final position = _musicQueue.enqueue(song); final position = _musicQueue.enqueue(song);
if (position == 0 && _musicQueue.currentSong == song) { if (position == 0 && _musicQueue.currentSong == song) {
await context.reply('Now playing: ${song.title}'); await context.replyAll('Now playing: ${song.title}');
} else if (position >= _config.maxQueueSize) {
await context.reply('Queue is full, could not add: ${song.title}');
} else { } else {
await context.reply('Added to queue at position $position: ${song.title}'); await context.reply('Added to queue at position ${position + 1}: ${song.title}');
} }
} catch (e) { } catch (e) {
_logManager.error('Failed to queue URL: $url', e); _logManager.error('Failed to queue URL: $url', e);
await context.reply('Failed to download or queue the audio: ${e.toString()}'); await context.reply('Failed to download or queue the audio: ${e.toString()}');
} }
} }
/// Handle the skip command /// Handle the skip command
Future<void> _handleSkipCommand(CommandContext context) async { Future<void> _handleSkipCommand(CommandContext context) async {
final currentSong = _musicQueue.currentSong; final currentSong = _musicQueue.currentSong;
if (currentSong == null) { if (currentSong == null) {
await context.reply('No song is currently playing.'); await context.reply('No song is currently playing.');
return; return;
} }
final skippedTitle = currentSong.title; final skippedTitle = currentSong.title;
if (_musicQueue.skip()) { if (_musicQueue.skip()) {
final nextSong = _musicQueue.currentSong; final nextSong = _musicQueue.currentSong;
if (nextSong != null) { if (nextSong != null) {
await context.reply('Skipped: $skippedTitle. Now playing: ${nextSong.title}'); await context.replyAll('Skipped: $skippedTitle. Now playing: ${nextSong.title}');
} else { } else {
await context.reply('Skipped: $skippedTitle. Queue is now empty.'); await context.replyAll('Skipped: $skippedTitle. Queue is now empty.');
} }
} else { } else {
await context.reply('Failed to skip the current song.'); await context.reply('Failed to skip the current song.');
} }
} }
/// Handle the list command /// Handle the list command
Future<void> _handleListCommand(CommandContext context) async { Future<void> _handleListCommand(CommandContext context) async {
final queue = _musicQueue.getQueue(); final queue = _musicQueue.getQueue();
final currentSong = _musicQueue.currentSong; final currentSong = _musicQueue.currentSong;
if (queue.isEmpty) { if (queue.isEmpty) {
await context.reply('Queue is empty.'); await context.reply('Queue is empty.');
return; return;
} }
final queueText = StringBuffer(); final queueText = StringBuffer();
queueText.writeln('Queue (${queue.length} songs):'); queueText.writeln('Queue (${queue.length} songs):');
for (var i = 0; i < queue.length; i++) { for (var i = 0; i < queue.length; i++) {
final song = queue[i]; final song = queue[i];
final prefix = (song == currentSong) ? '▶️ ' : '${i + 1}. '; final prefix = (song == currentSong) ? '▶️ ' : '${i + 1}. ';
queueText.writeln('$prefix${song.title} (${_formatDuration(song.duration)})'); queueText.writeln('$prefix${song.title} (${_formatDuration(song.duration)})');
} }
await context.reply(queueText.toString()); await context.reply(queueText.toString());
} }
/// Handle the clear command /// Handle the clear command
Future<void> _handleClearCommand(CommandContext context) async { Future<void> _handleClearCommand(CommandContext context) async {
_musicQueue.clear(); _musicQueue.clear();
await context.reply('Queue cleared.'); await context.replyAll('Queue cleared.');
} }
/// Handle the shuffle command /// Handle the shuffle command
Future<void> _handleShuffleCommand(CommandContext context) async { Future<void> _handleShuffleCommand(CommandContext context) async {
try { try {
final cachedSongs = await _downloader.getCachedSongs(); final cachedSongs = await _downloader.getCachedSongs();
if (cachedSongs.isEmpty) { if (cachedSongs.isEmpty) {
await context.reply('No cached songs found. Queue some songs first.'); await context.reply('No cached songs found. Queue some songs first.');
return; return;
} }
// Clear the queue and add shuffled songs // Clear the queue and add shuffled songs
_musicQueue.clear(); _musicQueue.clear();
// Shuffle the songs // Shuffle the songs
cachedSongs.shuffle(); cachedSongs.shuffle();
// Take up to max queue size // Take up to max queue size
final songsToAdd = cachedSongs.take(_config.maxQueueSize).toList(); final songsToAdd = cachedSongs.take(_config.maxQueueSize).toList();
// Add to queue // Add to queue
for (final song in songsToAdd) { for (final song in songsToAdd) {
_musicQueue.enqueue(song, song == songsToAdd.first); _musicQueue.enqueue(song, song == songsToAdd.first);
} }
await context.reply( await context.replyAll(
'Added ${songsToAdd.length} shuffled songs to the queue. ' 'Added ${songsToAdd.length} shuffled songs to the queue.\nNow playing: ${songsToAdd.first.title}',
'Now playing: ${songsToAdd.first.title}'
); );
} catch (e) { } catch (e) {
_logManager.error('Failed to shuffle songs', e); _logManager.error('Failed to shuffle songs', e);
await context.reply('Failed to shuffle songs: ${e.toString()}'); 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 /// Extract URL from HTML <a> tag or return the original string if not an HTML link
String _extractUrlFromHtml(String input) { String _extractUrlFromHtml(String input) {
final trimmed = input.trim(); final trimmed = input.trim();
// Check if the input looks like an HTML <a> tag // Check if the input looks like an HTML <a> tag
final aTagRegex = RegExp(r'<a\s+href="([^"]+)"[^>]*>.*?</a>', caseSensitive: false); final aTagRegex = RegExp(r'<a\s+href="([^"]+)"[^>]*>.*?</a>', caseSensitive: false);
final match = aTagRegex.firstMatch(trimmed); final match = aTagRegex.firstMatch(trimmed);
if (match != null) { if (match != null) {
final url = match.group(1)!; final url = match.group(1)!;
_logManager.debug('Extracted URL from HTML: $url (original: $trimmed)'); _logManager.debug('Extracted URL from HTML: $url (original: $trimmed)');
return url; return url;
} }
// If it's not an HTML link, return the original input // If it's not an HTML link, return the original input
return trimmed; return trimmed;
} }

View File

@ -13,7 +13,7 @@ class Command {
final int requiredPermissionLevel; final int requiredPermissionLevel;
final bool requiresArgs; final bool requiresArgs;
final Future<void> Function(CommandContext) execute; final Future<void> Function(CommandContext) execute;
Command({ Command({
required this.name, required this.name,
required this.description, required this.description,
@ -31,10 +31,13 @@ class CommandContext {
final MumbleUser sender; final MumbleUser sender;
final bool isPrivate; final bool isPrivate;
final int permissionLevel; final int permissionLevel;
/// Function to reply to the channel
final Future<void> Function(String) replyAll;
/// Function to reply to the sender /// Function to reply to the sender
final Future<void> Function(String) reply; final Future<void> Function(String) reply;
CommandContext({ CommandContext({
required this.commandName, required this.commandName,
required this.args, required this.args,
@ -42,45 +45,36 @@ class CommandContext {
required this.isPrivate, required this.isPrivate,
required this.permissionLevel, required this.permissionLevel,
required this.reply, required this.reply,
required this.replyAll,
}); });
} }
/// Result of a command execution /// Result of a command execution
enum CommandResult { enum CommandResult { success, notFound, noPermission, invalidArguments, error }
success,
notFound,
noPermission,
invalidArguments,
error,
}
/// Class for parsing and executing commands /// Class for parsing and executing commands
class CommandParser { class CommandParser {
final MumbleMessageHandler _messageHandler; final MumbleMessageHandler _messageHandler;
final BotConfig _config; final BotConfig _config;
final LogManager _logManager; final LogManager _logManager;
/// Map of command name to command /// Map of command name to command
final Map<String, Command> _commands = {}; final Map<String, Command> _commands = {};
/// Function to get permission level /// Function to get permission level
final Future<int> Function(MumbleUser) _getPermissionLevel; final Future<int> Function(MumbleUser) _getPermissionLevel;
/// Create a new command parser /// Create a new command parser
CommandParser( CommandParser(this._messageHandler, this._config, this._getPermissionLevel) : _logManager = LogManager.getInstance() {
this._messageHandler,
this._config,
this._getPermissionLevel,
) : _logManager = LogManager.getInstance() {
_setupCommandListener(); _setupCommandListener();
} }
/// Register a command /// Register a command
void registerCommand(Command command) { void registerCommand(Command command) {
_commands[command.name] = command; _commands[command.name] = command;
_logManager.debug('Registered command: ${command.name}'); _logManager.debug('Registered command: ${command.name}');
} }
/// Set up the command listener /// Set up the command listener
void _setupCommandListener() { void _setupCommandListener() {
_messageHandler.commandStream.listen((data) async { _messageHandler.commandStream.listen((data) async {
@ -88,20 +82,15 @@ class CommandParser {
final args = data['args'] as String; final args = data['args'] as String;
final sender = data['sender'] as MumbleUser; final sender = data['sender'] as MumbleUser;
final isPrivate = data['isPrivate'] as bool; final isPrivate = data['isPrivate'] as bool;
await _handleCommand(command, args, sender, isPrivate); await _handleCommand(command, args, sender, isPrivate);
}); });
} }
/// Handle a command /// Handle a command
Future<CommandResult> _handleCommand( Future<CommandResult> _handleCommand(String command, String args, MumbleUser sender, bool isPrivate) async {
String command,
String args,
MumbleUser sender,
bool isPrivate,
) async {
_logManager.info('Handling command: $command, args: $args, from: ${sender.name}'); _logManager.info('Handling command: $command, args: $args, from: ${sender.name}');
// Check if command exists // Check if command exists
if (!_commands.containsKey(command)) { if (!_commands.containsKey(command)) {
_logManager.warning('Command not found: $command'); _logManager.warning('Command not found: $command');
@ -112,40 +101,27 @@ class CommandParser {
); );
return CommandResult.notFound; return CommandResult.notFound;
} }
final cmd = _commands[command]!; final cmd = _commands[command]!;
// Check if user has permission // Check if user has permission
final permissionLevel = await _getPermissionLevel(sender); final permissionLevel = await _getPermissionLevel(sender);
if (permissionLevel < cmd.requiredPermissionLevel) { if (permissionLevel < cmd.requiredPermissionLevel) {
_logManager.warning( _logManager.warning(
'User ${sender.name} does not have permission to use command: $command ' 'User ${sender.name} does not have permission to use command: $command '
'(has: $permissionLevel, required: ${cmd.requiredPermissionLevel})' '(has: $permissionLevel, required: ${cmd.requiredPermissionLevel})',
);
await _messageHandler.replyToMessage(
'You do not have permission to use this command.',
sender,
isPrivate,
); );
await _messageHandler.replyToMessage('You do not have permission to use this command.', sender, true);
return CommandResult.noPermission; return CommandResult.noPermission;
} }
// Check if command requires arguments // Check if command requires arguments
if (cmd.requiresArgs && args.trim().isEmpty) { if (cmd.requiresArgs && args.trim().isEmpty) {
_logManager.warning('Command $command requires arguments, but none provided'); _logManager.warning('Command $command requires arguments, but none provided');
await _messageHandler.replyToMessage( await _messageHandler.replyToMessage('Usage: ${_config.commandPrefix}${cmd.usage}', sender, true);
'Usage: ${_config.commandPrefix}${cmd.usage}',
sender,
isPrivate,
);
return CommandResult.invalidArguments; return CommandResult.invalidArguments;
} }
// Create a reply function
final reply = (String message) {
return _messageHandler.replyToMessage(message, sender, isPrivate);
};
// Create command context // Create command context
final context = CommandContext( final context = CommandContext(
commandName: command, commandName: command,
@ -153,33 +129,28 @@ class CommandParser {
sender: sender, sender: sender,
isPrivate: isPrivate, isPrivate: isPrivate,
permissionLevel: permissionLevel, permissionLevel: permissionLevel,
reply: reply, reply: (String message) => _messageHandler.replyToMessage(message, sender, true),
replyAll: (String message) => _messageHandler.replyToMessage(message, sender, false),
); );
// Execute the command // Execute the command
try { try {
await cmd.execute(context); await cmd.execute(context);
return CommandResult.success; return CommandResult.success;
} catch (e, stackTrace) { } catch (e, stackTrace) {
_logManager.error('Error executing command: $command', e, stackTrace); _logManager.error('Error executing command: $command', e, stackTrace);
await _messageHandler.replyToMessage( await _messageHandler.replyToMessage('An error occurred while executing the command.', sender, isPrivate);
'An error occurred while executing the command.',
sender,
isPrivate,
);
return CommandResult.error; return CommandResult.error;
} }
} }
/// Get a list of all commands /// Get a list of all commands
List<Command> getCommands() { List<Command> getCommands() {
return _commands.values.toList(); return _commands.values.toList();
} }
/// Get commands filtered by permission level /// Get commands filtered by permission level
List<Command> getCommandsForPermissionLevel(int permissionLevel) { List<Command> getCommandsForPermissionLevel(int permissionLevel) {
return _commands.values return _commands.values.where((cmd) => cmd.requiredPermissionLevel <= permissionLevel).toList();
.where((cmd) => cmd.requiredPermissionLevel <= permissionLevel)
.toList();
} }
} }

View File

@ -59,7 +59,7 @@ class DashboardApi {
'id': song.id, 'id': song.id,
'title': song.title, 'title': song.title,
'duration': song.duration, 'duration': song.duration,
'url': song.url, 'url': song.url, // Will handle null gracefully in JSON serialization
'is_current': song == currentSong, 'is_current': song == currentSong,
}).toList(), }).toList(),
'state': state.name, 'state': state.name,
@ -67,7 +67,7 @@ class DashboardApi {
'id': currentSong.id, 'id': currentSong.id,
'title': currentSong.title, 'title': currentSong.title,
'duration': currentSong.duration, 'duration': currentSong.duration,
'url': currentSong.url, 'url': currentSong.url, // Will handle null gracefully in JSON serialization
}, },
}; };

View File

@ -24,27 +24,77 @@ class DashboardAuth {
Middleware get middleware { Middleware get middleware {
return (Handler innerHandler) { return (Handler innerHandler) {
return (Request request) async { return (Request request) async {
// Skip auth for login page and API // Get the cleaned path (without query parameters)
final path = request.url.path; final path = request.url.path;
if (path == 'login' || path == 'api/login') { final fullUrl = request.url.toString();
_logManager.info('Auth middleware checking path: $path (full URL: $fullUrl)');
_logManager.debug('Request method: ${request.method}');
// Paths that don't require authentication
final publicPaths = ['login', 'api/login', 'style.css', 'script.js', 'favicon.ico'];
final publicExtensions = ['.css', '.js', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.woff', '.woff2', '.ttf', '.eot'];
// Check if path is explicitly public or has a public extension
final isPublicPath = publicPaths.contains(path) || publicPaths.any((p) => path.startsWith(p));
final hasPublicExtension = publicExtensions.any((ext) => path.endsWith(ext));
if (isPublicPath || hasPublicExtension) {
_logManager.info('Skipping auth for public path: $path (isPublicPath: $isPublicPath, hasPublicExtension: $hasPublicExtension)');
return innerHandler(request); return innerHandler(request);
} }
// Dump all request headers for debugging
_logManager.debug('Request headers for path $path:');
request.headers.forEach((name, value) {
_logManager.debug(' $name: $value');
});
// Check for session token // Check for session token
final token = _getSessionToken(request); final token = _getSessionToken(request);
if (token != null && _validateSession(token)) { if (token != null) {
// Renew session _logManager.info('Found session token: $token');
_renewSession(token);
return innerHandler(request); if (_validateSession(token)) {
_logManager.info('Session token is valid, renewing session');
// Renew session
_renewSession(token);
// Process the request with the inner handler
final response = await innerHandler(request);
// Return the response with the cookie renewed
// This ensures the cookie is kept fresh on each authenticated request
return response.change(headers: {
...response.headers,
'set-cookie': 'session=$token; Path=/; HttpOnly; SameSite=Lax; Max-Age=${_sessionDurationMinutes * 60}',
});
} else {
_logManager.warning('Session token is invalid or expired');
}
} else {
_logManager.info('No session token found for path: $path');
} }
// Not authenticated // Not authenticated
if (path.startsWith('api/')) { if (path.startsWith('api/')) {
_logManager.warning('Unauthenticated API request: $path');
// API request, return 401 // API request, return 401
return Response.unauthorized('Unauthorized'); return Response(401,
body: json.encode({'error': 'Unauthorized'}),
headers: {
'content-type': 'application/json',
'cache-control': 'no-store, no-cache, must-revalidate, max-age=0',
'pragma': 'no-cache',
},
);
} else { } else {
_logManager.info('Redirecting to login page from: $path');
// Web request, redirect to login page // Web request, redirect to login page
return Response.found('/login'); return Response.found('/login', headers: {
'cache-control': 'no-store, no-cache, must-revalidate, max-age=0',
'pragma': 'no-cache',
});
} }
}; };
}; };
@ -57,35 +107,93 @@ class DashboardAuth {
if (contentType == 'application/json') { if (contentType == 'application/json') {
// API login // API login
final jsonString = await request.readAsString(); final jsonString = await request.readAsString();
final Map<String, dynamic> body = json.decode(jsonString); _logManager.info('API login request received: $jsonString');
final username = body['username'] as String?; try {
final password = body['password'] as String?; final Map<String, dynamic> body = json.decode(jsonString);
if (username == _config.adminUsername && password == _config.adminPassword) { final username = body['username'] as String?;
final token = _createSession(); final password = body['password'] as String?;
return Response.ok(
json.encode({'token': token}), _logManager.info('API login attempt - Username: $username');
headers: {'content-type': 'application/json'},
); if (username == _config.adminUsername && password == _config.adminPassword) {
} else { _logManager.info('API login successful');
return Response(401, body: json.encode({'error': 'Invalid credentials'})); final token = _createSession();
// Return with proper JSON response and set cookie header
final cookie = 'session=$token; Path=/; HttpOnly; SameSite=Lax; Max-Age=${_sessionDurationMinutes * 60}';
_logManager.info('API login: Setting cookie: $cookie');
return Response.ok(
json.encode({'success': true, 'message': 'Login successful'}),
headers: {
'content-type': 'application/json',
'set-cookie': cookie,
'cache-control': 'no-store, no-cache, must-revalidate, max-age=0',
'pragma': 'no-cache',
},
);
} else {
_logManager.warning('API login failed - Invalid credentials');
return Response(401, body: json.encode({'error': 'Invalid credentials'}), headers: {'content-type': 'application/json'});
}
} catch (e) {
_logManager.error('Failed to parse JSON login data', e);
return Response.badRequest(body: json.encode({'error': 'Invalid request format'}), headers: {'content-type': 'application/json'});
} }
} else { } else {
// Form login // Form login
final formData = await request.readAsString(); final formData = await request.readAsString();
final params = Uri.splitQueryString(formData); Map<String, String> params = {};
// Handle form-urlencoded data
if (request.headers['content-type']?.contains('application/x-www-form-urlencoded') ?? false) {
params = Uri.splitQueryString(formData);
_logManager.info('Form data: $params');
} else {
// Try to parse as JSON if not form-urlencoded
try {
final jsonData = json.decode(formData) as Map<String, dynamic>;
params = jsonData.map((key, value) => MapEntry(key, value.toString()));
_logManager.info('JSON data: $params');
} catch (e) {
_logManager.warning('Failed to parse form data: $e');
// Print out the raw data for debugging
_logManager.info('Raw data: $formData');
}
}
final username = params['username']; final username = params['username'];
final password = params['password']; final password = params['password'];
_logManager.info('Login attempt - Username: $username, Expected: ${_config.adminUsername}');
_logManager.info('Config password: ${_config.adminPassword}, Received password length: ${password?.length ?? 0}');
if (username == _config.adminUsername && password == _config.adminPassword) { if (username == _config.adminUsername && password == _config.adminPassword) {
_logManager.info('Login successful');
final token = _createSession(); final token = _createSession();
// Add debug logs for the token
_logManager.info('Generated session token: $token');
// Return with proper cookie format and set max-age
// Ensure compatibility with modern browsers by using appropriate cookie attributes
// Secure flag omitted for local development but should be added in production
final cookie = 'session=$token; Path=/; HttpOnly; SameSite=Lax; Max-Age=${_sessionDurationMinutes * 60}';
_logManager.info('Setting cookie: $cookie');
return Response.found( return Response.found(
'/', '/',
headers: {'set-cookie': 'session=$token; Path=/; HttpOnly'}, headers: {
'set-cookie': cookie,
// Add cache control headers to prevent caching issues with redirects
'cache-control': 'no-store, no-cache, must-revalidate, max-age=0',
'pragma': 'no-cache',
},
); );
} else { } else {
_logManager.warning('Login failed - Invalid credentials');
return Response.found('/login?error=invalid'); return Response.found('/login?error=invalid');
} }
} }
@ -110,30 +218,61 @@ class DashboardAuth {
final bytes = List<int>.generate(32, (_) => random.nextInt(256)); final bytes = List<int>.generate(32, (_) => random.nextInt(256));
final token = base64Url.encode(bytes); final token = base64Url.encode(bytes);
_sessions[token] = DateTime.now().add(Duration(minutes: _sessionDurationMinutes)); final expiration = DateTime.now().add(Duration(minutes: _sessionDurationMinutes));
_sessions[token] = expiration;
_logManager.info('Created new session: ${token.substring(0, 8)}...');
_logManager.debug(' Token length: ${token.length} characters');
_logManager.debug(' Token created: ${DateTime.now().toIso8601String()}');
_logManager.debug(' Token expires: ${expiration.toIso8601String()}');
_logManager.debug(' Session will expire in ${_sessionDurationMinutes} minutes');
_logManager.debug(' Total active sessions: ${_sessions.length}');
_logManager.info('Created new session');
return token; return token;
} }
/// Validate a session token /// Validate a session token
bool _validateSession(String token) { bool _validateSession(String token) {
_logManager.info('Validating session token: $token');
// Log the current state of all sessions for debugging
_logManager.debug('Current sessions:');
_sessions.forEach((sessionToken, expiry) {
_logManager.debug(' Session: ${sessionToken.substring(0, 8)}... expires at: ${expiry.toIso8601String()}');
});
final expiration = _sessions[token]; final expiration = _sessions[token];
if (expiration == null) { if (expiration == null) {
_logManager.warning('No session found for token: $token');
return false; return false;
} }
if (expiration.isBefore(DateTime.now())) { final now = DateTime.now();
if (expiration.isBefore(now)) {
_logManager.warning('Session expired at ${expiration.toIso8601String()}, current time: ${now.toIso8601String()}');
_sessions.remove(token); _sessions.remove(token);
return false; return false;
} }
final timeRemaining = expiration.difference(now).inMinutes;
_logManager.info('Session valid until ${expiration.toIso8601String()} (${timeRemaining} minutes remaining)');
return true; return true;
} }
/// Renew a session /// Renew a session
void _renewSession(String token) { void _renewSession(String token) {
_sessions[token] = DateTime.now().add(Duration(minutes: _sessionDurationMinutes)); final oldExpiration = _sessions[token];
final newExpiration = DateTime.now().add(Duration(minutes: _sessionDurationMinutes));
_sessions[token] = newExpiration;
_logManager.info('Renewed session: $token');
if (oldExpiration != null) {
_logManager.debug(' Old expiration: ${oldExpiration.toIso8601String()}');
}
_logManager.debug(' New expiration: ${newExpiration.toIso8601String()}');
_logManager.debug(' Session will expire in ${_sessionDurationMinutes} minutes');
} }
/// Get the session token from a request /// Get the session token from a request
@ -141,27 +280,72 @@ class DashboardAuth {
// Check authorization header // Check authorization header
final authHeader = request.headers['authorization']; final authHeader = request.headers['authorization'];
if (authHeader != null && authHeader.startsWith('Bearer ')) { if (authHeader != null && authHeader.startsWith('Bearer ')) {
return authHeader.substring(7); final token = authHeader.substring(7);
_logManager.debug('Found token in Authorization header: $token');
return token;
} }
// Check cookie // Check cookie
final cookies = request.headers['cookie']; final cookies = request.headers['cookie'];
if (cookies != null) { if (cookies != null) {
_logManager.info('Found cookies: $cookies');
// Enhanced cookie parsing
final cookieParts = cookies.split(';'); final cookieParts = cookies.split(';');
for (final part in cookieParts) { for (final part in cookieParts) {
final cookie = part.trim().split('='); final trimmedPart = part.trim();
if (cookie.length == 2 && cookie[0] == 'session') { _logManager.debug('Processing cookie part: $trimmedPart');
return cookie[1];
// Handle both 'session=value' and ' session=value' formats
if (trimmedPart.startsWith('session=')) {
final token = trimmedPart.substring('session='.length);
_logManager.info('Found session token in cookie: $token');
return token;
} else {
// More flexible split approach for unusual formatting
final cookie = trimmedPart.split('=');
if (cookie.length >= 2 && cookie[0].trim() == 'session') {
final token = cookie.sublist(1).join('='); // Handle values that might contain '=' characters
_logManager.info('Found session token in cookie using alternative parsing: $token');
return token;
}
} }
} }
} }
_logManager.info('No session token found in request');
// Dump all request headers for debugging
_logManager.debug('All request headers:');
request.headers.forEach((name, value) {
_logManager.debug(' $name: $value');
});
return null; return null;
} }
/// Clean up expired sessions /// Clean up expired sessions
void cleanupSessions() { void cleanupSessions() {
final now = DateTime.now(); final now = DateTime.now();
final initialCount = _sessions.length;
// Create a list of expired sessions for logging
final expiredSessions = <String>[];
_sessions.forEach((token, expiration) {
if (expiration.isBefore(now)) {
expiredSessions.add('${token.substring(0, 8)}...');
}
});
// Remove expired sessions
_sessions.removeWhere((_, expiration) => expiration.isBefore(now)); _sessions.removeWhere((_, expiration) => expiration.isBefore(now));
final removedCount = initialCount - _sessions.length;
if (removedCount > 0) {
_logManager.info('Cleaned up $removedCount expired sessions');
_logManager.debug(' Expired sessions: ${expiredSessions.join(', ')}');
}
_logManager.debug('Session cleanup completed. Active sessions: ${_sessions.length}');
} }
} }

View File

@ -20,68 +20,57 @@ class DashboardServer {
final YoutubeDownloader _downloader; final YoutubeDownloader _downloader;
final PermissionManager _permissionManager; final PermissionManager _permissionManager;
final LogManager _logManager; final LogManager _logManager;
HttpServer? _server; HttpServer? _server;
final DashboardAuth _auth; final DashboardAuth _auth;
/// Create a new dashboard server /// Create a new dashboard server
DashboardServer( DashboardServer(this._config, this._musicQueue, this._downloader, this._permissionManager)
this._config, : _logManager = LogManager.getInstance(),
this._musicQueue,
this._downloader,
this._permissionManager,
) : _logManager = LogManager.getInstance(),
_auth = DashboardAuth(_config); _auth = DashboardAuth(_config);
/// Start the dashboard server /// Start the dashboard server
Future<void> start() async { Future<void> start() async {
_logManager.info('Starting dashboard server on port ${_config.port}'); _logManager.info('Starting dashboard server on port ${_config.port}');
try { try {
// Create static file handler // Create static file handler
final staticHandler = createStaticHandler( final staticHandler = createStaticHandler(_getWebRoot(), defaultDocument: 'index.html');
_getWebRoot(),
defaultDocument: 'index.html',
);
// Create API router // Create API router
final api = DashboardApi( final api = DashboardApi(_musicQueue, _downloader, _permissionManager, _auth);
_musicQueue,
_downloader,
_permissionManager,
_auth,
);
// Create main router // Create main router
final router = Router(); final router = Router();
// API routes // API routes
router.mount('/api', api.router); router.mount('/api', api.router);
// Login page // Login page
router.get('/login', _handleLoginPage); router.get('/login', _handleLoginPage);
// All other routes go to static files // All other routes go to static files
router.all('/<ignored|.*>', (Request request) { router.all('/<ignored|.*>', (Request request) {
return staticHandler(request); return staticHandler(request);
}); });
// Create pipeline with auth middleware // Create pipeline with middleware for CORS, auth, and logging
final handler = Pipeline() final handler = Pipeline()
.addMiddleware(_corsHeaders())
.addMiddleware(_auth.middleware) .addMiddleware(_auth.middleware)
.addMiddleware(_logRequests()) .addMiddleware(_logRequests())
.addHandler(router); .addHandler(router);
// Start server // Start server
_server = await serve(handler, InternetAddress.anyIPv4, _config.port); _server = await serve(handler, InternetAddress.anyIPv4, _config.port);
_logManager.info('Dashboard server running at http://localhost:${_config.port}'); _logManager.info('Dashboard server running at http://localhost:${_config.port}');
} catch (e, stackTrace) { } catch (e, stackTrace) {
_logManager.error('Failed to start dashboard server', e, stackTrace); _logManager.error('Failed to start dashboard server', e, stackTrace);
rethrow; rethrow;
} }
} }
/// Stop the dashboard server /// Stop the dashboard server
Future<void> stop() async { Future<void> stop() async {
if (_server != null) { if (_server != null) {
@ -90,81 +79,76 @@ class DashboardServer {
_server = null; _server = null;
} }
} }
/// Get the path to the web root directory /// Get the path to the web root directory
String _getWebRoot() { String _getWebRoot() {
// Use path relative to executable // Use path relative to executable
final executable = Platform.script.toFilePath(); final executable = Platform.script.toFilePath();
final executableDir = path.dirname(executable); final executableDir = path.dirname(executable);
// Try to find web directory in common locations // Try to find web directory in common locations
final possiblePaths = [ final possiblePaths = [
path.join(executableDir, 'web'), path.join(executableDir, 'web'),
path.join(path.dirname(executableDir), 'web'), path.join(path.dirname(executableDir), 'web'),
path.join(path.dirname(path.dirname(executableDir)), 'web'), path.join(path.dirname(path.dirname(executableDir)), 'web'),
]; ];
for (final webPath in possiblePaths) { for (final webPath in possiblePaths) {
if (Directory(webPath).existsSync()) { if (Directory(webPath).existsSync()) {
return webPath; return webPath;
} }
} }
// Fall back to creating a temporary web directory // Fall back to creating a temporary web directory
final tempWebDir = path.join(executableDir, 'web'); final tempWebDir = path.join(executableDir, 'web');
_createDefaultWebFiles(tempWebDir); _createDefaultWebFiles(tempWebDir);
return tempWebDir; return tempWebDir;
} }
/// Create default web files if they don't exist /// Create default web files if they don't exist
void _createDefaultWebFiles(String webDir) { void _createDefaultWebFiles(String webDir) {
final webDirFile = Directory(webDir); final webDirFile = Directory(webDir);
if (!webDirFile.existsSync()) { if (!webDirFile.existsSync()) {
webDirFile.createSync(recursive: true); webDirFile.createSync(recursive: true);
} }
// Create index.html // Create index.html
final indexPath = path.join(webDir, 'index.html'); final indexPath = path.join(webDir, 'index.html');
if (!File(indexPath).existsSync()) { if (!File(indexPath).existsSync()) {
File(indexPath).writeAsStringSync(_getDefaultIndexHtml()); File(indexPath).writeAsStringSync(_getDefaultIndexHtml());
} }
// Create login.html // Create login.html
final loginPath = path.join(webDir, 'login.html'); final loginPath = path.join(webDir, 'login.html');
if (!File(loginPath).existsSync()) { if (!File(loginPath).existsSync()) {
File(loginPath).writeAsStringSync(_getDefaultLoginHtml()); File(loginPath).writeAsStringSync(_getDefaultLoginHtml());
} }
// Create style.css // Create style.css
final cssPath = path.join(webDir, 'style.css'); final cssPath = path.join(webDir, 'style.css');
if (!File(cssPath).existsSync()) { if (!File(cssPath).existsSync()) {
File(cssPath).writeAsStringSync(_getDefaultCss()); File(cssPath).writeAsStringSync(_getDefaultCss());
} }
// Create script.js // Create script.js
final jsPath = path.join(webDir, 'script.js'); final jsPath = path.join(webDir, 'script.js');
if (!File(jsPath).existsSync()) { if (!File(jsPath).existsSync()) {
File(jsPath).writeAsStringSync(_getDefaultJs()); File(jsPath).writeAsStringSync(_getDefaultJs());
} }
} }
/// Handle the login page /// Handle the login page
Response _handleLoginPage(Request request) { Response _handleLoginPage(Request request) {
final queryParams = request.url.queryParameters; final queryParams = request.url.queryParameters;
final error = queryParams['error']; final error = queryParams['error'];
final errorHtml = error == 'invalid' final errorHtml = error == 'invalid' ? '<div class="error">Invalid username or password</div>' : '';
? '<div class="error">Invalid username or password</div>'
: '';
final html = _getDefaultLoginHtml().replaceAll('<!--ERROR-->', errorHtml); final html = _getDefaultLoginHtml().replaceAll('<!--ERROR-->', errorHtml);
return Response.ok( return Response.ok(html, headers: {'content-type': 'text/html'});
html,
headers: {'content-type': 'text/html'},
);
} }
/// Middleware for logging requests /// Middleware for logging requests
Middleware _logRequests() { Middleware _logRequests() {
return (Handler innerHandler) { return (Handler innerHandler) {
@ -173,17 +157,42 @@ class DashboardServer {
final response = await innerHandler(request); final response = await innerHandler(request);
final endTime = DateTime.now(); final endTime = DateTime.now();
final duration = endTime.difference(startTime).inMilliseconds; final duration = endTime.difference(startTime).inMilliseconds;
_logManager.debug( _logManager.debug(
'${request.method} ${request.url.path} - ' '${request.method} ${request.url.path} - '
'${response.statusCode} (${duration}ms)' '${response.statusCode} (${duration}ms)',
); );
return response; return response;
}; };
}; };
} }
/// Middleware for adding CORS headers
Middleware _corsHeaders() {
return (Handler innerHandler) {
return (Request request) async {
// Get the response from the inner handler
final response = await innerHandler(request);
// Add CORS headers to the response
return response.change(
headers: {
...response.headers,
// Allow requests from the same origin (no cross-origin requests needed for now)
'Access-Control-Allow-Origin': request.headers['origin'] ?? '*',
// Allow cookies and auth headers
'Access-Control-Allow-Credentials': 'true',
// Allow common headers
'Access-Control-Allow-Headers': 'Origin, Content-Type, Accept, Authorization, Cookie, X-Requested-With',
// Allow common methods
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
},
);
};
};
}
/// Get the default index.html content /// Get the default index.html content
String _getDefaultIndexHtml() { String _getDefaultIndexHtml() {
return '''<!DOCTYPE html> return '''<!DOCTYPE html>
@ -318,7 +327,7 @@ class DashboardServer {
</body> </body>
</html>'''; </html>''';
} }
/// Get the default login.html content /// Get the default login.html content
String _getDefaultLoginHtml() { String _getDefaultLoginHtml() {
return '''<!DOCTYPE html> return '''<!DOCTYPE html>
@ -357,7 +366,7 @@ class DashboardServer {
</body> </body>
</html>'''; </html>''';
} }
/// Get the default CSS content /// Get the default CSS content
String _getDefaultCss() { String _getDefaultCss() {
return '''/* Base styles */ return '''/* Base styles */
@ -680,283 +689,23 @@ main {
} }
}'''; }''';
} }
/// Get the default JavaScript content /// Get the default JavaScript content
String _getDefaultJs() { String _getDefaultJs() {
// Create and return JavaScript content for the dashboard // Instead of defining JavaScript inline with lots of errors in Dart analysis,
// This is returned as a string, and won't be analyzed by Dart // we'll just return the contents of the script.js file we created in the web folder.
var js = ''' // This simplifies maintenance and avoids Dart analysis issues.
// DOM Elements final scriptFile = File(path.join(_getWebRoot(), 'script.js'));
const queueTab = document.getElementById('queueTab'); if (scriptFile.existsSync()) {
const usersTab = document.getElementById('usersTab'); return scriptFile.readAsStringSync();
const cacheTab = document.getElementById('cacheTab'); }
const logoutButton = document.getElementById('logoutButton');
// If the file doesn't exist for some reason, return a minimal script
const queueSection = document.getElementById('queueSection'); return '''
const usersSection = document.getElementById('usersSection'); // Basic script for dashboard functionality
const cacheSection = document.getElementById('cacheSection'); document.addEventListener('DOMContentLoaded', () => {
console.log('Dashboard loaded');
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;
}
}

View File

@ -14,21 +14,20 @@ class MumbleMessageHandler {
final MumbleConnection _connection; final MumbleConnection _connection;
final BotConfig _config; final BotConfig _config;
final LogManager _logManager; final LogManager _logManager;
/// Stream controller for incoming commands /// Stream controller for incoming commands
final _commandController = StreamController<Map<String, dynamic>>.broadcast(); final _commandController = StreamController<Map<String, dynamic>>.broadcast();
final List<StreamSubscription> _subscriptions = []; final List<StreamSubscription> _subscriptions = [];
/// Create a new message handler /// Create a new message handler
MumbleMessageHandler(this._connection, this._config) MumbleMessageHandler(this._connection, this._config) : _logManager = LogManager.getInstance() {
: _logManager = LogManager.getInstance() {
_setupHandlers(); _setupHandlers();
} }
/// Stream of incoming commands /// Stream of incoming commands
Stream<Map<String, dynamic>> get commandStream => _commandController.stream; Stream<Map<String, dynamic>> get commandStream => _commandController.stream;
/// Set up message handlers /// Set up message handlers
void _setupHandlers() { void _setupHandlers() {
// Wait for connection // Wait for connection
@ -41,13 +40,13 @@ class MumbleMessageHandler {
_cleanupMessageHandlers(); _cleanupMessageHandlers();
} }
}); });
// Setup immediately if already connected // Setup immediately if already connected
if (_connection.isConnected) { if (_connection.isConnected) {
_setupMessageHandlers(); _setupMessageHandlers();
} }
} }
/// Set up message handlers for the current connection /// Set up message handlers for the current connection
void _setupMessageHandlers() { void _setupMessageHandlers() {
final client = _connection.client; final client = _connection.client;
@ -55,20 +54,20 @@ class MumbleMessageHandler {
_logManager.warning('Cannot set up message handlers: client is null'); _logManager.warning('Cannot set up message handlers: client is null');
return; return;
} }
// Listen to the connection's text message stream // Listen to the connection's text message stream
final textMessageSub = _connection.textMessages.listen((data) { final textMessageSub = _connection.textMessages.listen((data) {
final message = data['message'] as String; final message = data['message'] as String;
final sender = data['sender'] as MumbleUser; final sender = data['sender'] as MumbleUser;
// For now, assume all messages are channel messages (not private) // For now, assume all messages are channel messages (not private)
_processMessage(message, sender, false); _processMessage(message, sender, false);
}); });
_subscriptions.add(textMessageSub); _subscriptions.add(textMessageSub);
_logManager.info('Message handlers set up'); _logManager.info('Message handlers set up');
} }
/// Clean up message handlers /// Clean up message handlers
void _cleanupMessageHandlers() { void _cleanupMessageHandlers() {
for (final subscription in _subscriptions) { for (final subscription in _subscriptions) {
@ -76,41 +75,34 @@ class MumbleMessageHandler {
} }
_subscriptions.clear(); _subscriptions.clear();
} }
/// Process an incoming message /// Process an incoming message
void _processMessage(String message, MumbleUser sender, bool isPrivate) { void _processMessage(String message, MumbleUser sender, bool isPrivate) {
// Check if message starts with command prefix // Check if message starts with command prefix
if (message.startsWith(_config.commandPrefix)) { if (message.startsWith(_config.commandPrefix)) {
final commandText = message.substring(_config.commandPrefix.length).trim(); final commandText = message.substring(_config.commandPrefix.length).trim();
if (commandText.isNotEmpty) { if (commandText.isNotEmpty) {
_logManager.info('Received command from ${sender.name}: $commandText'); _logManager.info('Received command from ${sender.name}: $commandText');
// Parse the command and arguments // Parse the command and arguments
final commandParts = commandText.split(' '); final commandParts = commandText.split(' ');
final command = commandParts[0].toLowerCase(); final command = commandParts[0].toLowerCase();
final args = commandParts.length > 1 final args = commandParts.length > 1 ? commandParts.sublist(1).join(' ') : '';
? commandParts.sublist(1).join(' ')
: '';
// Emit the command // Emit the command
_commandController.add({ _commandController.add({'command': command, 'args': args, 'sender': sender, 'isPrivate': isPrivate});
'command': command,
'args': args,
'sender': sender,
'isPrivate': isPrivate,
});
} }
} }
} }
/// Send a message to a channel /// Send a message to a channel
Future<void> sendChannelMessage(String message, [MumbleChannel? channel]) async { Future<void> sendChannelMessage(String message, [MumbleChannel? channel]) async {
if (!_connection.isConnected || _connection.client == null) { if (!_connection.isConnected || _connection.client == null) {
_logManager.error('Cannot send message: not connected'); _logManager.error('Cannot send message: not connected');
return; return;
} }
try { try {
// TODO: Implement proper channel message sending with dumble // TODO: Implement proper channel message sending with dumble
// For now, we'll use the connection's sendChannelMessage method // For now, we'll use the connection's sendChannelMessage method
@ -121,16 +113,15 @@ class MumbleMessageHandler {
rethrow; rethrow;
} }
} }
/// Send a private message to a user /// Send a private message to a user
Future<void> sendPrivateMessage(String message, MumbleUser user) async { Future<void> sendPrivateMessage(String message, MumbleUser user) async {
if (!_connection.isConnected || _connection.client == null) { if (!_connection.isConnected || _connection.client == null) {
_logManager.error('Cannot send message: not connected'); _logManager.error('Cannot send message: not connected');
return; return;
} }
try { try {
// TODO: Implement proper private message sending with dumble
// For now, we'll use the connection's sendPrivateMessage method // For now, we'll use the connection's sendPrivateMessage method
await _connection.sendPrivateMessage(message, user); await _connection.sendPrivateMessage(message, user);
_logManager.debug('Sent private message to ${user.name}: $message'); _logManager.debug('Sent private message to ${user.name}: $message');
@ -139,16 +130,16 @@ class MumbleMessageHandler {
rethrow; rethrow;
} }
} }
/// Reply to a message in the same context it was received /// Reply to a message in the same context it was received
Future<void> replyToMessage(String message, MumbleUser recipient, bool wasPrivate) async { Future<void> replyToMessage(String message, MumbleUser recipient, bool private) async {
if (wasPrivate) { if (private) {
await sendPrivateMessage(message, recipient); await sendPrivateMessage(message, recipient);
} else { } else {
await sendChannelMessage(message); await sendChannelMessage(message);
} }
} }
/// Dispose the message handler /// Dispose the message handler
void dispose() { void dispose() {
_cleanupMessageHandlers(); _cleanupMessageHandlers();

View File

@ -82,7 +82,7 @@ class MusicQueue {
// Check if queue is at max size // Check if queue is at max size
if (_queue.length >= _config.maxQueueSize) { if (_queue.length >= _config.maxQueueSize) {
_logManager.warning('Queue is full, cannot add song: ${song.title}'); _logManager.warning('Queue is full, cannot add song: ${song.title}');
return -1; return _queue.length; // Return current queue size instead of -1
} }
// Add to queue // Add to queue
@ -211,7 +211,9 @@ class MusicQueue {
await _audioStreamer.streamAudioFile(song.filePath); await _audioStreamer.streamAudioFile(song.filePath);
// Wait for playback to complete or be skipped // Wait for playback to complete or be skipped
await _playbackCompleter?.future; if (!_playbackCompleter!.isCompleted) {
_playbackCompleter!.complete();
}
_logManager.info('Song finished: ${song.title}'); _logManager.info('Song finished: ${song.title}');
_emitEvent(QueueEvent.songFinished, {'song': song}); _emitEvent(QueueEvent.songFinished, {'song': song});
@ -221,6 +223,11 @@ class MusicQueue {
} catch (e, stackTrace) { } catch (e, stackTrace) {
_logManager.error('Error playing song: ${song.title}', e, stackTrace); _logManager.error('Error playing song: ${song.title}', e, stackTrace);
// Complete the completer if it's not already completed
if (_playbackCompleter != null && !_playbackCompleter!.isCompleted) {
_playbackCompleter!.complete();
}
// Skip to next song on error // Skip to next song on error
_playNext(); _playNext();
} }

View File

@ -0,0 +1 @@
void main() {}

131
web/index.html Normal file
View File

@ -0,0 +1,131 @@
<!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>

85
web/login.html Normal file
View File

@ -0,0 +1,85 @@
<!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 id="loginForm" 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 id="loginError" class="error" style="display: none;">
Invalid username or password.
</div>
<div class="form-actions">
<button type="submit">Login</button>
</div>
</form>
<script>
// Check if there's an error parameter in the URL
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('error') && urlParams.get('error') === 'invalid') {
document.getElementById('loginError').style.display = 'block';
}
// Handle form submission with fetch API instead of traditional form submission
document.getElementById('loginForm').addEventListener('submit', async function(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: `username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`,
// Important for cookies to work
credentials: 'include'
});
if (response.redirected) {
// Follow the redirect manually
window.location.href = response.url;
return;
}
if (response.ok) {
// Successful login, redirect to dashboard
window.location.href = '/';
} else {
// Show error message
document.getElementById('loginError').style.display = 'block';
console.error('Login failed:', await response.text());
}
} catch (error) {
console.error('Login error:', error);
document.getElementById('loginError').textContent = 'An error occurred. Please try again.';
document.getElementById('loginError').style.display = 'block';
}
});
</script>
</div>
</div>
</div>
</body>
</html>

287
web/script.js Normal file
View File

@ -0,0 +1,287 @@
// 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',
credentials: 'include', // Important for cookies to work
});
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', {
credentials: 'include', // Important for cookies to work
});
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 && 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);
queueList.innerHTML = '<li class="empty-message">Failed to load queue</li>';
}
}
clearQueueButton.addEventListener('click', async () => {
if (!confirm('Are you sure you want to clear the queue?')) return;
try {
await fetch('/api/queue', {
method: 'DELETE',
credentials: 'include', // Important for cookies to work
});
loadQueue();
} catch (error) {
console.error('Failed to clear queue:', error);
alert('Failed to clear queue');
}
});
// User management
async function loadUsers() {
try {
const response = await fetch('/api/users', {
credentials: 'include', // Important for cookies to work
});
const responseData = await response.json();
if (responseData.users && 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',
'Accept': 'application/json'
},
body: JSON.stringify({ permission_level: permissionLevel }),
credentials: 'include', // Important for cookies to work
});
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);
usersTable.querySelector('tbody').innerHTML = '<tr class="empty-message"><td colspan="3">Failed to load users</td></tr>';
}
}
// 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 }),
credentials: 'include', // Important for cookies to work
});
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', {
credentials: 'include', // Important for cookies to work
headers: {
'Accept': 'application/json'
}
});
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',
credentials: 'include', // Important for cookies to work
});
loadCacheStats();
} catch (error) {
console.error('Failed to clear cache:', error);
alert('Failed to clear cache');
}
});
// Initial load
document.addEventListener('DOMContentLoaded', () => {
loadQueue();
loadUsers();
loadCacheStats();
// Refresh data periodically
setInterval(loadQueue, 10000); // Every 10 seconds
setInterval(loadCacheStats, 30000); // Every 30 seconds
});

319
web/style.css Normal file
View File

@ -0,0 +1,319 @@
/* 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;
}
}