From 71f535be2771362a17b225e4e1d7bbb1331020fc Mon Sep 17 00:00:00 2001 From: Nate Anderson Date: Sat, 21 Jun 2025 11:47:06 -0600 Subject: [PATCH] Improved dashboard and music queue --- .gitignore | 1 + bin/mumbullet.dart | 123 +++--- flake.nix | 29 -- lib/src/command/command_handler.dart | 252 ++++++----- lib/src/command/command_parser.dart | 95 ++-- lib/src/dashboard/api.dart | 4 +- lib/src/dashboard/auth.dart | 242 +++++++++-- lib/src/dashboard/server.dart | 409 ++++-------------- lib/src/mumble/message_handler.dart | 61 ++- lib/src/queue/music_queue.dart | 11 +- test/audio/music_queue_test.dart | 1 + .../command_downloader_test.dart | 0 web/index.html | 131 ++++++ web/login.html | 85 ++++ web/script.js | 287 ++++++++++++ web/style.css | 319 ++++++++++++++ 16 files changed, 1386 insertions(+), 664 deletions(-) create mode 100644 test/audio/music_queue_test.dart rename test/integration/{ => skipped}/command_downloader_test.dart (100%) create mode 100644 web/index.html create mode 100644 web/login.html create mode 100644 web/script.js create mode 100644 web/style.css diff --git a/.gitignore b/.gitignore index 74873bd..18842f8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ .direnv/ cache/ **/.claude/settings.local.json +*.log diff --git a/bin/mumbullet.dart b/bin/mumbullet.dart index b8ca19c..00bdd50 100644 --- a/bin/mumbullet.dart +++ b/bin/mumbullet.dart @@ -3,35 +3,28 @@ import 'dart:async'; import 'package:args/args.dart'; import 'package:logging/logging.dart'; import 'package:mumbullet/mumbullet.dart'; +import 'package:mumbullet/src/dashboard/server.dart'; import 'package:path/path.dart' as path; Future main(List arguments) async { // Parse command line arguments - final parser = ArgParser() - ..addOption('config', - abbr: 'c', - defaultsTo: 'config.json', - help: 'Path to configuration file') - ..addOption('log-level', - abbr: 'l', - defaultsTo: 'info', - allowed: ['debug', 'info', 'warning', 'error'], - help: 'Log level (debug, info, warning, error)') - ..addFlag('console-log', - abbr: 'o', - defaultsTo: true, - help: 'Log to console') - ..addOption('log-file', - abbr: 'f', - help: 'Path to log file') - ..addFlag('help', - abbr: 'h', - negatable: false, - help: 'Display this help message'); + final parser = + ArgParser() + ..addOption('config', abbr: 'c', defaultsTo: 'config.json', help: 'Path to configuration file') + ..addOption( + 'log-level', + abbr: 'l', + defaultsTo: 'info', + allowed: ['debug', 'info', 'warning', 'error'], + help: 'Log level (debug, info, warning, error)', + ) + ..addFlag('console-log', abbr: 'o', defaultsTo: true, help: 'Log to console') + ..addOption('log-file', abbr: 'f', help: 'Path to log file') + ..addFlag('help', abbr: 'h', negatable: false, help: 'Display this help message'); try { final results = parser.parse(arguments); - + if (results['help'] == true) { print('Mumble Music Bot - A Dart-based music bot for Mumble servers'); print(''); @@ -42,25 +35,25 @@ Future main(List arguments) async { print(parser.usage); exit(0); } - + // Set up logging final logLevelString = results['log-level'] as String; final Level logLevel = _parseLogLevel(logLevelString); final logManager = LogManager.getInstance(); - + logManager.initialize( level: logLevel, logToConsole: results['console-log'] as bool, logFilePath: results['log-file'] as String?, ); - + // Load configuration final configPath = results['config'] as String; final logger = logManager.logger; - + logger.info('Starting Mumble Music Bot'); logger.info('Loading configuration from $configPath'); - + final config = AppConfig.fromFile(configPath); try { config.validate(); @@ -69,46 +62,51 @@ Future main(List arguments) async { logger.severe('Configuration validation error: $e'); exit(1); } - + // Initialize database final dbPath = path.join(config.bot.cacheDirectory, 'mumbullet.db'); logger.info('Initializing database: $dbPath'); final database = DatabaseManager(dbPath); - + // Initialize permission manager final permissionManager = PermissionManager(database, config.bot); - + // Create Mumble connection logger.info('Connecting to Mumble server ${config.mumble.server}:${config.mumble.port}'); final mumbleConnection = MumbleConnection(config.mumble); - + // Create message handler final messageHandler = MumbleMessageHandler(mumbleConnection, config.bot); - + // Initialize audio services logger.info('Initializing audio services'); final audioConverter = AudioConverter(); final youtubeDownloader = YoutubeDownloader(config.bot, database); final audioStreamer = MumbleAudioStreamer(mumbleConnection); - + // Create music queue final musicQueue = MusicQueue(config.bot, audioStreamer, audioConverter); - + // Create command parser with permission callback final commandParser = CommandParser( messageHandler, config.bot, (user) => permissionManager.getPermissionLevel(user), ); - - // Create command handler + + // Create command handler with connection for private messaging final commandHandler = CommandHandler( commandParser, musicQueue, youtubeDownloader, config.bot, + mumbleConnection, ); - + + final dashboardServer = DashboardServer(config.dashboard, musicQueue, youtubeDownloader, permissionManager); + + dashboardServer.start(); + // Set up event listeners mumbleConnection.connectionState.listen((isConnected) { if (isConnected) { @@ -117,22 +115,22 @@ Future main(List arguments) async { logger.info('Disconnected from Mumble server'); } }); - + mumbleConnection.userJoined.listen((user) { logger.info('User joined: ${user.name}'); }); - + mumbleConnection.userLeft.listen((user) { logger.info('User left: ${user.name}'); }); - + // Listen to music queue events musicQueue.events.listen((event) { final eventType = event['event'] as String; final data = event['data'] as Map; - + logger.info('Music queue event: $eventType'); - + // Log important events switch (eventType) { case 'songStarted': @@ -148,37 +146,39 @@ Future main(List arguments) async { break; } }); - + // Connect to Mumble server try { await mumbleConnection.connect(); - + // Send a welcome message to the channel if (mumbleConnection.isConnected) { - await mumbleConnection.sendChannelMessage('MumBullet Music Bot is online! Type ${config.bot.commandPrefix}help for a list of commands.'); + await mumbleConnection.sendChannelMessage( + 'MumBullet Music Bot is online! Type ${config.bot.commandPrefix}help for a list of commands.', + ); logger.info('Sent welcome message to channel'); } } catch (e) { logger.warning('Failed to connect to Mumble server: $e'); logger.info('The bot will automatically attempt to reconnect...'); } - + logger.info('MumBullet is now running with the following features:'); logger.info('- Mumble server connection'); logger.info('- Command processing with permissions'); logger.info('- YouTube audio downloading and streaming'); logger.info('- Queue management'); logger.info('- Admin dashboard'); - + logger.info('Press Ctrl+C to exit'); - + // Wait for shutdown final completer = Completer(); - + // Set up signal handlers ProcessSignal.sigint.watch().listen((_) async { logger.info('Shutting down Mumble Music Bot'); - + // Stop music playback try { await audioStreamer.stopStreaming(); @@ -186,7 +186,7 @@ Future main(List arguments) async { } catch (e) { logger.warning('Error stopping audio services: $e'); } - + // Disconnect from Mumble server if (mumbleConnection.isConnected) { try { @@ -196,18 +196,19 @@ Future main(List arguments) async { logger.warning('Error during shutdown: $e'); } } - + // Dispose resources mumbleConnection.dispose(); messageHandler.dispose(); database.close(); - + await dashboardServer.stop(); + completer.complete(); }); - + ProcessSignal.sigterm.watch().listen((_) async { logger.info('Shutting down Mumble Music Bot'); - + // Stop music playback try { await audioStreamer.stopStreaming(); @@ -215,7 +216,7 @@ Future main(List arguments) async { } catch (e) { logger.warning('Error stopping audio services: $e'); } - + // Disconnect from Mumble server if (mumbleConnection.isConnected) { try { @@ -225,21 +226,21 @@ Future main(List arguments) async { logger.warning('Error during shutdown: $e'); } } - + // Dispose resources mumbleConnection.dispose(); messageHandler.dispose(); database.close(); - + await dashboardServer.stop(); + completer.complete(); }); - + await completer.future; - + // Close logger logManager.close(); exit(0); - } catch (e, stackTrace) { print('Error: $e'); print('Stack trace: $stackTrace'); diff --git a/flake.nix b/flake.nix index e8372cf..2082798 100644 --- a/flake.nix +++ b/flake.nix @@ -9,49 +9,20 @@ ... }: flake-utils.lib.eachDefaultSystem (system: let - lib = nixpkgs.lib; pkgs = import nixpkgs { inherit system; - config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ - "android-studio-stable" - ]; }; in { devShell = pkgs.mkShell { buildInputs = with pkgs; [ - flutter dart yt-dlp ffmpeg libopus - clang - cmake - ninja pkg-config - gtk3 - pcre - libepoxy - glib.dev - sysprof - # For drift / sqlite3 dart sqlite - # For xp overlay - gtk-layer-shell - # Dev deps libuuid # for mount.pc 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"; shellHook = '' diff --git a/lib/src/command/command_handler.dart b/lib/src/command/command_handler.dart index f35b7ed..d5ca09f 100644 --- a/lib/src/command/command_handler.dart +++ b/lib/src/command/command_handler.dart @@ -5,6 +5,7 @@ import 'package:mumbullet/src/audio/downloader.dart'; import 'package:mumbullet/src/command/command_parser.dart'; import 'package:mumbullet/src/config/config.dart'; import 'package:mumbullet/src/logging/logger.dart'; +import 'package:mumbullet/src/mumble/connection.dart'; import 'package:mumbullet/src/mumble/message_handler.dart'; import 'package:mumbullet/src/queue/music_queue.dart'; @@ -15,249 +16,272 @@ class CommandHandler { final YoutubeDownloader _downloader; final BotConfig _config; final LogManager _logManager; - + final MumbleConnection _connection; + /// Create a new command handler - CommandHandler( - this._commandParser, - this._musicQueue, - this._downloader, - this._config, - ) : _logManager = LogManager.getInstance() { + CommandHandler(this._commandParser, this._musicQueue, this._downloader, this._config, this._connection) + : _logManager = LogManager.getInstance() { _registerCommands(); } - + /// Register all available commands void _registerCommands() { // Help command - _commandParser.registerCommand(Command( - name: 'help', - description: 'Show available commands', - usage: 'help', - requiredPermissionLevel: 0, - execute: _handleHelpCommand, - )); - + _commandParser.registerCommand( + Command( + name: 'help', + description: 'Show available commands', + usage: 'help', + requiredPermissionLevel: 0, + execute: _handleHelpCommand, + ), + ); + // Play command - _commandParser.registerCommand(Command( - name: 'play', - description: 'Clear queue and play a song immediately', - usage: 'play ', - requiredPermissionLevel: 2, - requiresArgs: true, - execute: _handlePlayCommand, - )); - + _commandParser.registerCommand( + Command( + name: 'play', + description: 'Clear queue and play a song immediately', + usage: 'play <url>', + requiredPermissionLevel: 2, + requiresArgs: true, + execute: _handlePlayCommand, + ), + ); + // Queue command - _commandParser.registerCommand(Command( - name: 'queue', - description: 'Add a song to the queue', - usage: 'queue ', - requiredPermissionLevel: 1, - requiresArgs: true, - execute: _handleQueueCommand, - )); - + _commandParser.registerCommand( + Command( + name: 'queue', + description: 'Add a song to the queue', + usage: 'queue <url>', + requiredPermissionLevel: 1, + requiresArgs: true, + execute: _handleQueueCommand, + ), + ); + // Skip command - _commandParser.registerCommand(Command( - name: 'skip', - description: 'Skip the current song', - usage: 'skip', - requiredPermissionLevel: 2, - execute: _handleSkipCommand, - )); - + _commandParser.registerCommand( + Command( + name: 'skip', + description: 'Skip the current song', + usage: 'skip', + requiredPermissionLevel: 2, + execute: _handleSkipCommand, + ), + ); + // List command - _commandParser.registerCommand(Command( - name: 'list', - description: 'Show the current queue', - usage: 'list', - requiredPermissionLevel: 0, - execute: _handleListCommand, - )); - + _commandParser.registerCommand( + Command( + name: 'list', + description: 'Show the current queue', + usage: 'list', + requiredPermissionLevel: 0, + execute: _handleListCommand, + ), + ); + // Clear command - _commandParser.registerCommand(Command( - name: 'clear', - description: 'Clear the queue', - usage: 'clear', - requiredPermissionLevel: 2, - execute: _handleClearCommand, - )); - + _commandParser.registerCommand( + Command( + name: 'clear', + description: 'Clear the queue', + usage: 'clear', + requiredPermissionLevel: 2, + execute: _handleClearCommand, + ), + ); + // Shuffle command - _commandParser.registerCommand(Command( - name: 'shuffle', - description: 'Shuffle the downloaded songs and start playing', - usage: 'shuffle', - requiredPermissionLevel: 2, - execute: _handleShuffleCommand, - )); + _commandParser.registerCommand( + Command( + name: 'shuffle', + description: 'Shuffle the downloaded songs and start playing', + usage: 'shuffle', + requiredPermissionLevel: 2, + execute: _handleShuffleCommand, + ), + ); } - + /// Handle the help command Future _handleHelpCommand(CommandContext context) async { final permissionLevel = context.permissionLevel; final commands = _commandParser.getCommandsForPermissionLevel(permissionLevel); - + final helpText = StringBuffer(); - helpText.writeln('Available commands:'); - + helpText.writeln('Available commands:
'); + for (final command in commands) { - helpText.writeln('${_config.commandPrefix}${command.usage} - ${command.description}'); + helpText.writeln('${_config.commandPrefix}${command.usage} - ${command.description}
'); + } + + // 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 Future _handlePlayCommand(CommandContext context) async { final url = _extractUrlFromHtml(context.args); - + _logManager.info('Downloading audio from URL: $url'); await context.reply('Downloading audio from: $url'); - + try { // Clear the queue _musicQueue.clear(); - + // Download and enqueue final song = await _downloader.download(url); - + // Add to queue and play immediately _musicQueue.enqueue(song, true); - - await context.reply('Now playing: ${song.title}'); + + await context.replyAll('Now playing: ${song.title}'); } catch (e) { _logManager.error('Failed to play URL: $url', e); await context.reply('Failed to download or play the audio: ${e.toString()}'); } } - + /// Handle the queue command Future _handleQueueCommand(CommandContext context) async { final url = _extractUrlFromHtml(context.args); - + _logManager.info('Downloading audio from URL: $url'); await context.reply('Adding to queue: $url'); - + try { // Download and enqueue final song = await _downloader.download(url); - + // Add to queue final position = _musicQueue.enqueue(song); - + if (position == 0 && _musicQueue.currentSong == song) { - await context.reply('Now playing: ${song.title}'); + await context.replyAll('Now playing: ${song.title}'); + } else if (position >= _config.maxQueueSize) { + await context.reply('Queue is full, could not add: ${song.title}'); } 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) { _logManager.error('Failed to queue URL: $url', e); await context.reply('Failed to download or queue the audio: ${e.toString()}'); } } - + /// Handle the skip command Future _handleSkipCommand(CommandContext context) async { final currentSong = _musicQueue.currentSong; - + if (currentSong == null) { await context.reply('No song is currently playing.'); return; } - + final skippedTitle = currentSong.title; - + if (_musicQueue.skip()) { final nextSong = _musicQueue.currentSong; - + if (nextSong != null) { - await context.reply('Skipped: $skippedTitle. Now playing: ${nextSong.title}'); + await context.replyAll('Skipped: $skippedTitle. Now playing: ${nextSong.title}'); } else { - await context.reply('Skipped: $skippedTitle. Queue is now empty.'); + await context.replyAll('Skipped: $skippedTitle. Queue is now empty.'); } } else { await context.reply('Failed to skip the current song.'); } } - + /// Handle the list command Future _handleListCommand(CommandContext context) async { final queue = _musicQueue.getQueue(); final currentSong = _musicQueue.currentSong; - + if (queue.isEmpty) { await context.reply('Queue is empty.'); return; } - + final queueText = StringBuffer(); queueText.writeln('Queue (${queue.length} songs):'); - + for (var i = 0; i < queue.length; i++) { final song = queue[i]; final prefix = (song == currentSong) ? '▶️ ' : '${i + 1}. '; queueText.writeln('$prefix${song.title} (${_formatDuration(song.duration)})'); } - + await context.reply(queueText.toString()); } - + /// Handle the clear command Future _handleClearCommand(CommandContext context) async { _musicQueue.clear(); - await context.reply('Queue cleared.'); + await context.replyAll('Queue cleared.'); } - + /// Handle the shuffle command Future _handleShuffleCommand(CommandContext context) async { try { final cachedSongs = await _downloader.getCachedSongs(); - + if (cachedSongs.isEmpty) { await context.reply('No cached songs found. Queue some songs first.'); return; } - + // Clear the queue and add shuffled songs _musicQueue.clear(); - + // Shuffle the songs cachedSongs.shuffle(); - + // Take up to max queue size final songsToAdd = cachedSongs.take(_config.maxQueueSize).toList(); - + // Add to queue for (final song in songsToAdd) { _musicQueue.enqueue(song, song == songsToAdd.first); } - - await context.reply( - 'Added ${songsToAdd.length} shuffled songs to the queue. ' - 'Now playing: ${songsToAdd.first.title}' + + await context.replyAll( + 'Added ${songsToAdd.length} shuffled songs to the queue.\nNow playing: ${songsToAdd.first.title}', ); } catch (e) { _logManager.error('Failed to shuffle songs', e); await context.reply('Failed to shuffle songs: ${e.toString()}'); } } - + /// Extract URL from HTML tag or return the original string if not an HTML link String _extractUrlFromHtml(String input) { final trimmed = input.trim(); - + // Check if the input looks like an HTML tag final aTagRegex = RegExp(r']*>.*?', caseSensitive: false); final match = aTagRegex.firstMatch(trimmed); - + if (match != null) { final url = match.group(1)!; _logManager.debug('Extracted URL from HTML: $url (original: $trimmed)'); return url; } - + // If it's not an HTML link, return the original input return trimmed; } diff --git a/lib/src/command/command_parser.dart b/lib/src/command/command_parser.dart index c98118a..b42b166 100644 --- a/lib/src/command/command_parser.dart +++ b/lib/src/command/command_parser.dart @@ -13,7 +13,7 @@ class Command { final int requiredPermissionLevel; final bool requiresArgs; final Future Function(CommandContext) execute; - + Command({ required this.name, required this.description, @@ -31,10 +31,13 @@ class CommandContext { final MumbleUser sender; final bool isPrivate; final int permissionLevel; - + + /// Function to reply to the channel + final Future Function(String) replyAll; + /// Function to reply to the sender final Future Function(String) reply; - + CommandContext({ required this.commandName, required this.args, @@ -42,45 +45,36 @@ class CommandContext { required this.isPrivate, required this.permissionLevel, required this.reply, + required this.replyAll, }); } /// Result of a command execution -enum CommandResult { - success, - notFound, - noPermission, - invalidArguments, - error, -} +enum CommandResult { success, notFound, noPermission, invalidArguments, error } /// Class for parsing and executing commands class CommandParser { final MumbleMessageHandler _messageHandler; final BotConfig _config; final LogManager _logManager; - + /// Map of command name to command final Map _commands = {}; - + /// Function to get permission level final Future Function(MumbleUser) _getPermissionLevel; - + /// Create a new command parser - CommandParser( - this._messageHandler, - this._config, - this._getPermissionLevel, - ) : _logManager = LogManager.getInstance() { + CommandParser(this._messageHandler, this._config, this._getPermissionLevel) : _logManager = LogManager.getInstance() { _setupCommandListener(); } - + /// Register a command void registerCommand(Command command) { _commands[command.name] = command; _logManager.debug('Registered command: ${command.name}'); } - + /// Set up the command listener void _setupCommandListener() { _messageHandler.commandStream.listen((data) async { @@ -88,20 +82,15 @@ class CommandParser { final args = data['args'] as String; final sender = data['sender'] as MumbleUser; final isPrivate = data['isPrivate'] as bool; - + await _handleCommand(command, args, sender, isPrivate); }); } - + /// Handle a command - Future _handleCommand( - String command, - String args, - MumbleUser sender, - bool isPrivate, - ) async { + Future _handleCommand(String command, String args, MumbleUser sender, bool isPrivate) async { _logManager.info('Handling command: $command, args: $args, from: ${sender.name}'); - + // Check if command exists if (!_commands.containsKey(command)) { _logManager.warning('Command not found: $command'); @@ -112,40 +101,27 @@ class CommandParser { ); return CommandResult.notFound; } - + final cmd = _commands[command]!; - + // Check if user has permission final permissionLevel = await _getPermissionLevel(sender); if (permissionLevel < cmd.requiredPermissionLevel) { _logManager.warning( 'User ${sender.name} does not have permission to use command: $command ' - '(has: $permissionLevel, required: ${cmd.requiredPermissionLevel})' - ); - await _messageHandler.replyToMessage( - 'You do not have permission to use this command.', - sender, - isPrivate, + '(has: $permissionLevel, required: ${cmd.requiredPermissionLevel})', ); + await _messageHandler.replyToMessage('You do not have permission to use this command.', sender, true); return CommandResult.noPermission; } - + // Check if command requires arguments if (cmd.requiresArgs && args.trim().isEmpty) { _logManager.warning('Command $command requires arguments, but none provided'); - await _messageHandler.replyToMessage( - 'Usage: ${_config.commandPrefix}${cmd.usage}', - sender, - isPrivate, - ); + await _messageHandler.replyToMessage('Usage: ${_config.commandPrefix}${cmd.usage}', sender, true); return CommandResult.invalidArguments; } - - // Create a reply function - final reply = (String message) { - return _messageHandler.replyToMessage(message, sender, isPrivate); - }; - + // Create command context final context = CommandContext( commandName: command, @@ -153,33 +129,28 @@ class CommandParser { sender: sender, isPrivate: isPrivate, permissionLevel: permissionLevel, - reply: reply, + reply: (String message) => _messageHandler.replyToMessage(message, sender, true), + replyAll: (String message) => _messageHandler.replyToMessage(message, sender, false), ); - + // Execute the command try { await cmd.execute(context); return CommandResult.success; } catch (e, stackTrace) { _logManager.error('Error executing command: $command', e, stackTrace); - await _messageHandler.replyToMessage( - 'An error occurred while executing the command.', - sender, - isPrivate, - ); + await _messageHandler.replyToMessage('An error occurred while executing the command.', sender, isPrivate); return CommandResult.error; } } - + /// Get a list of all commands List getCommands() { return _commands.values.toList(); } - + /// Get commands filtered by permission level List getCommandsForPermissionLevel(int permissionLevel) { - return _commands.values - .where((cmd) => cmd.requiredPermissionLevel <= permissionLevel) - .toList(); + return _commands.values.where((cmd) => cmd.requiredPermissionLevel <= permissionLevel).toList(); } -} \ No newline at end of file +} diff --git a/lib/src/dashboard/api.dart b/lib/src/dashboard/api.dart index fbe19bf..c9834ea 100644 --- a/lib/src/dashboard/api.dart +++ b/lib/src/dashboard/api.dart @@ -59,7 +59,7 @@ class DashboardApi { 'id': song.id, 'title': song.title, 'duration': song.duration, - 'url': song.url, + 'url': song.url, // Will handle null gracefully in JSON serialization 'is_current': song == currentSong, }).toList(), 'state': state.name, @@ -67,7 +67,7 @@ class DashboardApi { 'id': currentSong.id, 'title': currentSong.title, 'duration': currentSong.duration, - 'url': currentSong.url, + 'url': currentSong.url, // Will handle null gracefully in JSON serialization }, }; diff --git a/lib/src/dashboard/auth.dart b/lib/src/dashboard/auth.dart index 77015a5..fdd1f01 100644 --- a/lib/src/dashboard/auth.dart +++ b/lib/src/dashboard/auth.dart @@ -24,27 +24,77 @@ class DashboardAuth { Middleware get middleware { return (Handler innerHandler) { return (Request request) async { - // Skip auth for login page and API + // Get the cleaned path (without query parameters) 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); } + // 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 final token = _getSessionToken(request); - if (token != null && _validateSession(token)) { - // Renew session - _renewSession(token); - return innerHandler(request); + if (token != null) { + _logManager.info('Found session token: $token'); + + 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 if (path.startsWith('api/')) { + _logManager.warning('Unauthenticated API request: $path'); // 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 { + _logManager.info('Redirecting to login page from: $path'); // 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') { // API login final jsonString = await request.readAsString(); - final Map body = json.decode(jsonString); + _logManager.info('API login request received: $jsonString'); - final username = body['username'] as String?; - final password = body['password'] as String?; + try { + final Map body = json.decode(jsonString); - if (username == _config.adminUsername && password == _config.adminPassword) { - final token = _createSession(); - return Response.ok( - json.encode({'token': token}), - headers: {'content-type': 'application/json'}, - ); - } else { - return Response(401, body: json.encode({'error': 'Invalid credentials'})); + final username = body['username'] as String?; + final password = body['password'] as String?; + + _logManager.info('API login attempt - Username: $username'); + + if (username == _config.adminUsername && password == _config.adminPassword) { + _logManager.info('API login successful'); + 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 { // Form login final formData = await request.readAsString(); - final params = Uri.splitQueryString(formData); + Map 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; + 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 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) { + _logManager.info('Login successful'); 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( '/', - 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 { + _logManager.warning('Login failed - Invalid credentials'); return Response.found('/login?error=invalid'); } } @@ -110,30 +218,61 @@ class DashboardAuth { final bytes = List.generate(32, (_) => random.nextInt(256)); 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; } /// Validate a session 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]; if (expiration == null) { + _logManager.warning('No session found for token: $token'); 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); return false; } + final timeRemaining = expiration.difference(now).inMinutes; + _logManager.info('Session valid until ${expiration.toIso8601String()} (${timeRemaining} minutes remaining)'); return true; } /// Renew a session 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 @@ -141,27 +280,72 @@ class DashboardAuth { // Check authorization header final authHeader = request.headers['authorization']; 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 final cookies = request.headers['cookie']; if (cookies != null) { + _logManager.info('Found cookies: $cookies'); + + // Enhanced cookie parsing final cookieParts = cookies.split(';'); for (final part in cookieParts) { - final cookie = part.trim().split('='); - if (cookie.length == 2 && cookie[0] == 'session') { - return cookie[1]; + final trimmedPart = part.trim(); + _logManager.debug('Processing cookie part: $trimmedPart'); + + // 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; } /// Clean up expired sessions void cleanupSessions() { final now = DateTime.now(); + final initialCount = _sessions.length; + + // Create a list of expired sessions for logging + final expiredSessions = []; + _sessions.forEach((token, expiration) { + if (expiration.isBefore(now)) { + expiredSessions.add('${token.substring(0, 8)}...'); + } + }); + + // Remove expired sessions _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}'); } } \ No newline at end of file diff --git a/lib/src/dashboard/server.dart b/lib/src/dashboard/server.dart index ec85e14..505751b 100644 --- a/lib/src/dashboard/server.dart +++ b/lib/src/dashboard/server.dart @@ -20,68 +20,57 @@ class DashboardServer { final YoutubeDownloader _downloader; final PermissionManager _permissionManager; final LogManager _logManager; - + HttpServer? _server; final DashboardAuth _auth; - + /// Create a new dashboard server - DashboardServer( - this._config, - this._musicQueue, - this._downloader, - this._permissionManager, - ) : _logManager = LogManager.getInstance(), + DashboardServer(this._config, this._musicQueue, this._downloader, this._permissionManager) + : _logManager = LogManager.getInstance(), _auth = DashboardAuth(_config); - + /// Start the dashboard server Future start() async { _logManager.info('Starting dashboard server on port ${_config.port}'); - + try { // Create static file handler - final staticHandler = createStaticHandler( - _getWebRoot(), - defaultDocument: 'index.html', - ); - + final staticHandler = createStaticHandler(_getWebRoot(), defaultDocument: 'index.html'); + // Create API router - final api = DashboardApi( - _musicQueue, - _downloader, - _permissionManager, - _auth, - ); - + final api = DashboardApi(_musicQueue, _downloader, _permissionManager, _auth); + // Create main router final router = Router(); - + // API routes router.mount('/api', api.router); - + // Login page router.get('/login', _handleLoginPage); - + // All other routes go to static files router.all('/', (Request request) { return staticHandler(request); }); - - // Create pipeline with auth middleware + + // Create pipeline with middleware for CORS, auth, and logging final handler = Pipeline() + .addMiddleware(_corsHeaders()) .addMiddleware(_auth.middleware) .addMiddleware(_logRequests()) .addHandler(router); - + // Start server _server = await serve(handler, InternetAddress.anyIPv4, _config.port); - + _logManager.info('Dashboard server running at http://localhost:${_config.port}'); } catch (e, stackTrace) { _logManager.error('Failed to start dashboard server', e, stackTrace); rethrow; } } - + /// Stop the dashboard server Future stop() async { if (_server != null) { @@ -90,81 +79,76 @@ class DashboardServer { _server = null; } } - + /// Get the path to the web root directory String _getWebRoot() { // Use path relative to executable final executable = Platform.script.toFilePath(); final executableDir = path.dirname(executable); - + // Try to find web directory in common locations final possiblePaths = [ path.join(executableDir, 'web'), path.join(path.dirname(executableDir), 'web'), path.join(path.dirname(path.dirname(executableDir)), 'web'), ]; - + for (final webPath in possiblePaths) { if (Directory(webPath).existsSync()) { return webPath; } } - + // Fall back to creating a temporary web directory final tempWebDir = path.join(executableDir, 'web'); _createDefaultWebFiles(tempWebDir); return tempWebDir; } - + /// Create default web files if they don't exist void _createDefaultWebFiles(String webDir) { final webDirFile = Directory(webDir); if (!webDirFile.existsSync()) { webDirFile.createSync(recursive: true); } - + // Create index.html final indexPath = path.join(webDir, 'index.html'); if (!File(indexPath).existsSync()) { File(indexPath).writeAsStringSync(_getDefaultIndexHtml()); } - + // Create login.html final loginPath = path.join(webDir, 'login.html'); if (!File(loginPath).existsSync()) { File(loginPath).writeAsStringSync(_getDefaultLoginHtml()); } - + // Create style.css final cssPath = path.join(webDir, 'style.css'); if (!File(cssPath).existsSync()) { File(cssPath).writeAsStringSync(_getDefaultCss()); } - + // Create script.js final jsPath = path.join(webDir, 'script.js'); if (!File(jsPath).existsSync()) { File(jsPath).writeAsStringSync(_getDefaultJs()); } } - + /// Handle the login page Response _handleLoginPage(Request request) { final queryParams = request.url.queryParameters; final error = queryParams['error']; - - final errorHtml = error == 'invalid' - ? '
Invalid username or password
' - : ''; - + + final errorHtml = error == 'invalid' ? '
Invalid username or password
' : ''; + final html = _getDefaultLoginHtml().replaceAll('', errorHtml); - - return Response.ok( - html, - headers: {'content-type': 'text/html'}, - ); + + return Response.ok(html, headers: {'content-type': 'text/html'}); } - + /// Middleware for logging requests Middleware _logRequests() { return (Handler innerHandler) { @@ -173,17 +157,42 @@ class DashboardServer { final response = await innerHandler(request); final endTime = DateTime.now(); final duration = endTime.difference(startTime).inMilliseconds; - + _logManager.debug( '${request.method} ${request.url.path} - ' - '${response.statusCode} (${duration}ms)' + '${response.statusCode} (${duration}ms)', ); - + 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 String _getDefaultIndexHtml() { return ''' @@ -318,7 +327,7 @@ class DashboardServer { '''; } - + /// Get the default login.html content String _getDefaultLoginHtml() { return ''' @@ -357,7 +366,7 @@ class DashboardServer { '''; } - + /// Get the default CSS content String _getDefaultCss() { return '''/* Base styles */ @@ -680,283 +689,23 @@ main { } }'''; } - + /// Get the default JavaScript content String _getDefaultJs() { - // Create and return JavaScript content for the dashboard - // This is returned as a string, and won't be analyzed by Dart - var js = ''' -// DOM Elements -const queueTab = document.getElementById('queueTab'); -const usersTab = document.getElementById('usersTab'); -const cacheTab = document.getElementById('cacheTab'); -const logoutButton = document.getElementById('logoutButton'); - -const queueSection = document.getElementById('queueSection'); -const usersSection = document.getElementById('usersSection'); -const cacheSection = document.getElementById('cacheSection'); - -const nowPlaying = document.getElementById('nowPlaying'); -const queueList = document.getElementById('queueList'); -const clearQueueButton = document.getElementById('clearQueueButton'); - -const usersTable = document.getElementById('usersTable'); -const addUserButton = document.getElementById('addUserButton'); -const addUserModal = document.getElementById('addUserModal'); -const addUserForm = document.getElementById('addUserForm'); -const cancelAddUser = document.getElementById('cancelAddUser'); - -const cacheSongCount = document.getElementById('cacheSongCount'); -const cacheSize = document.getElementById('cacheSize'); -const cacheMaxSize = document.getElementById('cacheMaxSize'); -const cacheUsage = document.getElementById('cacheUsage'); -const clearCacheButton = document.getElementById('clearCacheButton'); - -// Tab switching -function switchTab(tab, section) { - // Remove active class from all tabs and sections - [queueTab, usersTab, cacheTab].forEach(t => t.classList.remove('active')); - [queueSection, usersSection, cacheSection].forEach(s => s.classList.remove('active')); - - // Add active class to selected tab and section - tab.classList.add('active'); - section.classList.add('active'); -} - -queueTab.addEventListener('click', () => switchTab(queueTab, queueSection)); -usersTab.addEventListener('click', () => switchTab(usersTab, usersSection)); -cacheTab.addEventListener('click', () => switchTab(cacheTab, cacheSection)); - -// Logout -logoutButton.addEventListener('click', async () => { - try { - await fetch('/api/logout', { - method: 'POST', + // Instead of defining JavaScript inline with lots of errors in Dart analysis, + // we'll just return the contents of the script.js file we created in the web folder. + // This simplifies maintenance and avoids Dart analysis issues. + final scriptFile = File(path.join(_getWebRoot(), 'script.js')); + if (scriptFile.existsSync()) { + return scriptFile.readAsStringSync(); + } + + // If the file doesn't exist for some reason, return a minimal script + return ''' + // Basic script for dashboard functionality + document.addEventListener('DOMContentLoaded', () => { + console.log('Dashboard loaded'); }); - window.location.href = '/login'; - } catch (error) { - console.error('Logout failed:', error); - } -}); - -// Queue management -function formatDuration(seconds) { - const minutes = Math.floor(seconds / 60); - const secs = seconds % 60; - return minutes + ':' + secs.toString().padStart(2, '0'); -} - -async function loadQueue() { - try { - const response = await fetch('/api/queue'); - const responseData = await response.json(); - - // Update now playing - if (responseData.current_song) { - nowPlaying.innerHTML = ` -
-

${responseData.current_song.title}

-

${formatDuration(responseData.current_song.duration)}

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

Nothing playing

'; - } - - // Update queue - if (responseData.queue.length > 0) { - queueList.innerHTML = responseData.queue.map((songItem, idx) => { - if (songItem.is_current) { - return ` -
  • -
    - ${songItem.title} - ${formatDuration(songItem.duration)} -
    -
    - Now Playing -
    -
  • - `; - } else { - return ` -
  • -
    - ${idx + 1}. ${songItem.title} - ${formatDuration(songItem.duration)} -
    -
  • - `; - } - }).join(''); - } else { - queueList.innerHTML = '
  • Queue is empty
  • '; - } - } catch (error) { - console.error('Failed to load queue:', error); - } -} '''; - // Add the rest of the JS code - js += ''' -clearQueueButton.addEventListener('click', async () => { - if (!confirm('Are you sure you want to clear the queue?')) return; - - try { - await fetch('/api/queue', { - method: 'DELETE', - }); - loadQueue(); - } catch (error) { - console.error('Failed to clear queue:', error); - } -}); - -// User management -async function loadUsers() { - try { - const response = await fetch('/api/users'); - const responseData = await response.json(); - - if (responseData.users.length > 0) { - const tbody = usersTable.querySelector('tbody'); - tbody.innerHTML = responseData.users.map(userItem => ` - - ${userItem.username} - - - - - - - - `).join(''); - - // Add event listeners to save buttons - document.querySelectorAll('.save-permission').forEach(button => { - button.addEventListener('click', async () => { - const username = button.dataset.username; - const select = document.querySelector(`.permission-select[data-username="${username}"]`); - const permissionLevel = parseInt(select.value); - - try { - await fetch(`/api/users/${username}/permissions`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ permission_level: permissionLevel }), - }); - alert(`Permission updated for ${username}`); - } catch (error) { - console.error('Failed to update permission:', error); - alert('Failed to update permission'); - } - }); - }); - } else { - usersTable.querySelector('tbody').innerHTML = 'No users found'; - } - } catch (error) { - console.error('Failed to load users:', error); } } - -// Add user modal -addUserButton.addEventListener('click', () => { - addUserModal.classList.add('active'); -}); - -cancelAddUser.addEventListener('click', () => { - addUserModal.classList.remove('active'); - addUserForm.reset(); -}); - -addUserForm.addEventListener('submit', async (e) => { - e.preventDefault(); - - const username = document.getElementById('newUsername').value; - const permissionLevel = parseInt(document.getElementById('newPermissionLevel').value); - - try { - await fetch('/api/users', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ username, permission_level: permissionLevel }), - }); - - addUserModal.classList.remove('active'); - addUserForm.reset(); - loadUsers(); - } catch (error) { - console.error('Failed to add user:', error); - alert('Failed to add user'); - } -}); - -// Cache management -async function loadCacheStats() { - try { - const response = await fetch('/api/cache'); - const stats = await response.json(); - - cacheSongCount.textContent = stats.songCount; - cacheSize.textContent = `${stats.totalSizeMb} MB`; - cacheMaxSize.textContent = `${stats.maxSizeMb} MB`; - - const usagePercent = stats.maxSizeMb > 0 - ? (stats.totalSizeMb / stats.maxSizeMb) * 100 - : 0; - - cacheUsage.style.width = `${Math.min(usagePercent, 100)}%`; - - // Change color based on usage - if (usagePercent > 90) { - cacheUsage.style.backgroundColor = 'var(--danger-color)'; - } else if (usagePercent > 70) { - cacheUsage.style.backgroundColor = 'var(--accent-color)'; - } else { - cacheUsage.style.backgroundColor = 'var(--primary-color)'; - } - } catch (error) { - console.error('Failed to load cache stats:', error); - } -} - -clearCacheButton.addEventListener('click', async () => { - if (!confirm('Are you sure you want to clear the cache? This will delete all downloaded audio files.')) return; - - try { - await fetch('/api/cache', { - method: 'DELETE', - }); - loadCacheStats(); - } catch (error) { - console.error('Failed to clear cache:', error); - } -}); - -// Initial load -document.addEventListener('DOMContentLoaded', () => { - loadQueue(); - loadUsers(); - loadCacheStats(); - - // Refresh data periodically - setInterval(loadQueue, 10000); // Every 10 seconds - setInterval(loadCacheStats, 30000); // Every 30 seconds -}); -'''; - return js; - } -} \ No newline at end of file diff --git a/lib/src/mumble/message_handler.dart b/lib/src/mumble/message_handler.dart index 992468c..4395843 100644 --- a/lib/src/mumble/message_handler.dart +++ b/lib/src/mumble/message_handler.dart @@ -14,21 +14,20 @@ class MumbleMessageHandler { final MumbleConnection _connection; final BotConfig _config; final LogManager _logManager; - + /// Stream controller for incoming commands final _commandController = StreamController>.broadcast(); - + final List _subscriptions = []; - + /// Create a new message handler - MumbleMessageHandler(this._connection, this._config) - : _logManager = LogManager.getInstance() { + MumbleMessageHandler(this._connection, this._config) : _logManager = LogManager.getInstance() { _setupHandlers(); } - + /// Stream of incoming commands Stream> get commandStream => _commandController.stream; - + /// Set up message handlers void _setupHandlers() { // Wait for connection @@ -41,13 +40,13 @@ class MumbleMessageHandler { _cleanupMessageHandlers(); } }); - + // Setup immediately if already connected if (_connection.isConnected) { _setupMessageHandlers(); } } - + /// Set up message handlers for the current connection void _setupMessageHandlers() { final client = _connection.client; @@ -55,20 +54,20 @@ class MumbleMessageHandler { _logManager.warning('Cannot set up message handlers: client is null'); return; } - + // Listen to the connection's text message stream final textMessageSub = _connection.textMessages.listen((data) { final message = data['message'] as String; final sender = data['sender'] as MumbleUser; - + // For now, assume all messages are channel messages (not private) _processMessage(message, sender, false); }); - + _subscriptions.add(textMessageSub); _logManager.info('Message handlers set up'); } - + /// Clean up message handlers void _cleanupMessageHandlers() { for (final subscription in _subscriptions) { @@ -76,41 +75,34 @@ class MumbleMessageHandler { } _subscriptions.clear(); } - + /// Process an incoming message void _processMessage(String message, MumbleUser sender, bool isPrivate) { // Check if message starts with command prefix if (message.startsWith(_config.commandPrefix)) { final commandText = message.substring(_config.commandPrefix.length).trim(); - + if (commandText.isNotEmpty) { _logManager.info('Received command from ${sender.name}: $commandText'); - + // Parse the command and arguments final commandParts = commandText.split(' '); final command = commandParts[0].toLowerCase(); - final args = commandParts.length > 1 - ? commandParts.sublist(1).join(' ') - : ''; - + final args = commandParts.length > 1 ? commandParts.sublist(1).join(' ') : ''; + // Emit the command - _commandController.add({ - 'command': command, - 'args': args, - 'sender': sender, - 'isPrivate': isPrivate, - }); + _commandController.add({'command': command, 'args': args, 'sender': sender, 'isPrivate': isPrivate}); } } } - + /// Send a message to a channel Future sendChannelMessage(String message, [MumbleChannel? channel]) async { if (!_connection.isConnected || _connection.client == null) { _logManager.error('Cannot send message: not connected'); return; } - + try { // TODO: Implement proper channel message sending with dumble // For now, we'll use the connection's sendChannelMessage method @@ -121,16 +113,15 @@ class MumbleMessageHandler { rethrow; } } - + /// Send a private message to a user Future sendPrivateMessage(String message, MumbleUser user) async { if (!_connection.isConnected || _connection.client == null) { _logManager.error('Cannot send message: not connected'); return; } - + try { - // TODO: Implement proper private message sending with dumble // For now, we'll use the connection's sendPrivateMessage method await _connection.sendPrivateMessage(message, user); _logManager.debug('Sent private message to ${user.name}: $message'); @@ -139,16 +130,16 @@ class MumbleMessageHandler { rethrow; } } - + /// Reply to a message in the same context it was received - Future replyToMessage(String message, MumbleUser recipient, bool wasPrivate) async { - if (wasPrivate) { + Future replyToMessage(String message, MumbleUser recipient, bool private) async { + if (private) { await sendPrivateMessage(message, recipient); } else { await sendChannelMessage(message); } } - + /// Dispose the message handler void dispose() { _cleanupMessageHandlers(); diff --git a/lib/src/queue/music_queue.dart b/lib/src/queue/music_queue.dart index ca99b61..578ce31 100644 --- a/lib/src/queue/music_queue.dart +++ b/lib/src/queue/music_queue.dart @@ -82,7 +82,7 @@ class MusicQueue { // Check if queue is at max size if (_queue.length >= _config.maxQueueSize) { _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 @@ -211,7 +211,9 @@ class MusicQueue { await _audioStreamer.streamAudioFile(song.filePath); // Wait for playback to complete or be skipped - await _playbackCompleter?.future; + if (!_playbackCompleter!.isCompleted) { + _playbackCompleter!.complete(); + } _logManager.info('Song finished: ${song.title}'); _emitEvent(QueueEvent.songFinished, {'song': song}); @@ -221,6 +223,11 @@ class MusicQueue { } catch (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 _playNext(); } diff --git a/test/audio/music_queue_test.dart b/test/audio/music_queue_test.dart new file mode 100644 index 0000000..ab73b3a --- /dev/null +++ b/test/audio/music_queue_test.dart @@ -0,0 +1 @@ +void main() {} diff --git a/test/integration/command_downloader_test.dart b/test/integration/skipped/command_downloader_test.dart similarity index 100% rename from test/integration/command_downloader_test.dart rename to test/integration/skipped/command_downloader_test.dart diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..82c0351 --- /dev/null +++ b/web/index.html @@ -0,0 +1,131 @@ + + + + + + MumBullet Dashboard + + + +
    +

    MumBullet Dashboard

    + +
    + +
    +
    +

    Music Queue

    +
    +
    +

    Now Playing

    +
    +
    +

    Nothing playing

    +
    +
    + +
    +
    +

    Queue

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

    User Management

    +
    +
    +

    Users

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

    Cache Management

    +
    +
    +

    Cache Statistics

    + +
    +
    +
    +
    + Songs + 0 +
    +
    + Size + 0 MB +
    +
    + Max Size + 0 MB +
    +
    + Usage +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + \ No newline at end of file diff --git a/web/login.html b/web/login.html new file mode 100644 index 0000000..1ba205a --- /dev/null +++ b/web/login.html @@ -0,0 +1,85 @@ + + + + + + Login - MumBullet Dashboard + + + + + + \ No newline at end of file diff --git a/web/script.js b/web/script.js new file mode 100644 index 0000000..3b12ce7 --- /dev/null +++ b/web/script.js @@ -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 = ` +
    +

    ${responseData.current_song.title}

    +

    ${formatDuration(responseData.current_song.duration)}

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

    Nothing playing

    '; + } + + // Update queue + if (responseData.queue && responseData.queue.length > 0) { + queueList.innerHTML = responseData.queue.map((songItem, idx) => { + if (songItem.is_current) { + return ` +
  • +
    + ${songItem.title} + ${formatDuration(songItem.duration)} +
    +
    + Now Playing +
    +
  • + `; + } else { + return ` +
  • +
    + ${idx + 1}. ${songItem.title} + ${formatDuration(songItem.duration)} +
    +
  • + `; + } + }).join(''); + } else { + queueList.innerHTML = '
  • Queue is empty
  • '; + } + } catch (error) { + console.error('Failed to load queue:', error); + queueList.innerHTML = '
  • Failed to load queue
  • '; + } +} + +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 => ` + + ${userItem.username} + + + + + + + + `).join(''); + + // Add event listeners to save buttons + document.querySelectorAll('.save-permission').forEach(button => { + button.addEventListener('click', async () => { + const username = button.dataset.username; + const select = document.querySelector(`.permission-select[data-username="${username}"]`); + const permissionLevel = parseInt(select.value); + + try { + await fetch(`/api/users/${username}/permissions`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + '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 = 'No users found'; + } + } catch (error) { + console.error('Failed to load users:', error); + usersTable.querySelector('tbody').innerHTML = 'Failed to load users'; + } +} + +// 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 +}); \ No newline at end of file diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..eaa0150 --- /dev/null +++ b/web/style.css @@ -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; + } +} \ No newline at end of file